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/renderer | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/renderer')
50 files changed, 13863 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/renderer/Metal.zig b/vendor/libghostty-vt/src/renderer/Metal.zig new file mode 100644 index 00000000..6c7432d2 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/Metal.zig @@ -0,0 +1,458 @@ +//! Graphics API wrapper for Metal. +pub const Metal = @This(); + +const std = @import("std"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const builtin = @import("builtin"); +const objc = @import("objc"); +const macos = @import("macos"); +const graphics = macos.graphics; +const apprt = @import("../apprt.zig"); +const font = @import("../font/main.zig"); +const configpkg = @import("../config.zig"); +const rendererpkg = @import("../renderer.zig"); +const Renderer = rendererpkg.GenericRenderer(Metal); +const shadertoy = @import("shadertoy.zig"); + +const mtl = @import("metal/api.zig"); +const IOSurfaceLayer = @import("metal/IOSurfaceLayer.zig"); + +pub const GraphicsAPI = Metal; +pub const Target = @import("metal/Target.zig"); +pub const Frame = @import("metal/Frame.zig"); +pub const RenderPass = @import("metal/RenderPass.zig"); +pub const Pipeline = @import("metal/Pipeline.zig"); +const bufferpkg = @import("metal/buffer.zig"); +pub const Buffer = bufferpkg.Buffer; +pub const Sampler = @import("metal/Sampler.zig"); +pub const Texture = @import("metal/Texture.zig"); +pub const shaders = @import("metal/shaders.zig"); + +pub const custom_shader_target: shadertoy.Target = .msl; +// The fragCoord for Metal shaders is +Y = down. +pub const custom_shader_y_is_down = true; + +/// Triple buffering. +pub const swap_chain_count = 3; + +const log = std.log.scoped(.metal); + +layer: IOSurfaceLayer, + +/// MTLDevice +device: objc.Object, +/// MTLCommandQueue +queue: objc.Object, + +/// Alpha blending mode +blending: configpkg.Config.AlphaBlending, + +/// The default storage mode to use for resources created with our device. +/// +/// This is based on whether the device is a discrete GPU or not, since +/// discrete GPUs do not have unified memory and therefore do not support +/// the "shared" storage mode, instead we have to use the "managed" mode. +default_storage_mode: mtl.MTLResourceOptions.StorageMode, + +/// The maximum 2D texture width and height supported by the device. +max_texture_size: u32, + +/// We start an AutoreleasePool before `drawFrame` and end it afterwards. +autorelease_pool: ?*objc.AutoreleasePool = null, + +pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal { + comptime switch (builtin.os.tag) { + .macos, .ios => {}, + else => @compileError("unsupported platform for Metal"), + }; + + _ = alloc; + + // Choose our MTLDevice and create a MTLCommandQueue for that device. + const device = try chooseDevice(); + errdefer device.release(); + const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{}); + errdefer queue.release(); + + // Grab metadata about the device. + const default_storage_mode: mtl.MTLResourceOptions.StorageMode = switch (comptime builtin.os.tag) { + // manage mode is not supported by iOS + .ios => .shared, + else => if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed, + }; + const max_texture_size = queryMaxTextureSize(device); + log.debug( + "device properties default_storage_mode={} max_texture_size={}", + .{ default_storage_mode, max_texture_size }, + ); + + const ViewInfo = struct { + view: objc.Object, + scaleFactor: f64, + }; + + // Get the metadata about our underlying view that we'll be rendering to. + const info: ViewInfo = switch (apprt.runtime) { + apprt.embedded => .{ + .scaleFactor = @floatCast(opts.rt_surface.content_scale.x), + .view = switch (opts.rt_surface.platform) { + .macos => |v| v.nsview, + .ios => |v| v.uiview, + }, + }, + + else => @compileError("unsupported apprt for metal"), + }; + + // Create an IOSurfaceLayer which we can assign to the view to make + // it in to a "layer-hosting view", so that we can manually control + // the layer contents. + var layer = try IOSurfaceLayer.init(); + errdefer layer.release(); + + // Add our layer to the view. + // + // On macOS we do this by making the view "layer-hosting" + // by assigning it to the view's `layer` property BEFORE + // setting `wantsLayer` to `true`. + // + // On iOS, views are always layer-backed, and `layer` + // is readonly, so instead we add it as a sublayer. + switch (comptime builtin.os.tag) { + .macos => { + info.view.setProperty("layer", layer.layer.value); + info.view.setProperty("wantsLayer", true); + }, + + .ios => { + const view_layer = objc.Object.fromId(info.view.getProperty(?*anyopaque, "layer")); + view_layer.msgSend(void, objc.sel("addSublayer:"), .{layer.layer.value}); + }, + + else => @compileError("unsupported target for Metal"), + } + + // Ensure that if our layer is oversized it + // does not overflow the bounds of the view. + info.view.setProperty("clipsToBounds", true); + + // Ensure that our layer has a content scale set to + // match the scale factor of the window. This avoids + // magnification issues leading to blurry rendering. + layer.layer.setProperty("contentsScale", info.scaleFactor); + + // This makes it so that our display callback will actually be called. + layer.layer.setProperty("needsDisplayOnBoundsChange", true); + + return .{ + .layer = layer, + .device = device, + .queue = queue, + .blending = opts.config.blending, + .default_storage_mode = default_storage_mode, + .max_texture_size = max_texture_size, + }; +} + +pub fn deinit(self: *Metal) void { + self.queue.release(); + self.device.release(); + self.layer.release(); +} + +pub fn loopEnter(self: *Metal) void { + const renderer: *align(1) Renderer = @fieldParentPtr("api", self); + self.layer.setDisplayCallback( + @ptrCast(&displayCallback), + @ptrCast(renderer), + ); +} + +fn displayCallback(renderer: *Renderer) align(8) void { + renderer.drawFrame(true) catch |err| { + log.warn("Error drawing frame in display callback, err={}", .{err}); + }; +} + +/// Actions taken before doing anything in `drawFrame`. +/// +/// Right now we use this to start an AutoreleasePool. +pub fn drawFrameStart(self: *Metal) void { + assert(self.autorelease_pool == null); + self.autorelease_pool = .init(); +} + +/// Actions taken after `drawFrame` is done. +/// +/// Right now we use this to end our AutoreleasePool. +pub fn drawFrameEnd(self: *Metal) void { + assert(self.autorelease_pool != null); + self.autorelease_pool.?.deinit(); + self.autorelease_pool = null; +} + +pub fn initShaders( + self: *const Metal, + alloc: Allocator, + custom_shaders: []const [:0]const u8, +) !shaders.Shaders { + return try shaders.Shaders.init( + alloc, + self.device, + custom_shaders, + // Using an `*_srgb` pixel format makes Metal gamma encode + // the pixels written to it *after* blending, which means + // we get linear alpha blending rather than gamma-incorrect + // blending. + if (self.blending.isLinear()) + mtl.MTLPixelFormat.bgra8unorm_srgb + else + mtl.MTLPixelFormat.bgra8unorm, + ); +} + +/// Get the current size of the runtime surface. +pub fn surfaceSize(self: *const Metal) !struct { width: u32, height: u32 } { + const bounds = self.layer.layer.getProperty(graphics.Rect, "bounds"); + const scale = self.layer.layer.getProperty(f64, "contentsScale"); + + // We need to clamp our runtime surface size to the maximum + // possible texture size since we can't create a screen buffer (texture) + // larger than that. + return .{ + .width = @min( + @as(u32, @intFromFloat(bounds.size.width * scale)), + self.max_texture_size, + ), + .height = @min( + @as(u32, @intFromFloat(bounds.size.height * scale)), + self.max_texture_size, + ), + }; +} + +/// Initialize a new render target which can be presented by this API. +pub fn initTarget(self: *const Metal, width: usize, height: usize) !Target { + return Target.init(.{ + .device = self.device, + // Using an `*_srgb` pixel format makes Metal gamma encode the pixels + // written to it *after* blending, which means we get linear alpha + // blending rather than gamma-incorrect blending. + .pixel_format = if (self.blending.isLinear()) + .bgra8unorm_srgb + else + .bgra8unorm, + .storage_mode = self.default_storage_mode, + .width = width, + .height = height, + }); +} + +/// Present the provided target. +pub inline fn present(self: *Metal, target: Target, sync: bool) !void { + if (sync) { + self.layer.setSurfaceSync(target.surface); + } else { + try self.layer.setSurface(target.surface); + } +} + +/// Present the last presented target again. (noop for Metal) +pub inline fn presentLastTarget(self: *Metal) !void { + _ = self; +} + +/// Returns the options to use when constructing buffers. +pub inline fn bufferOptions(self: Metal) bufferpkg.Options { + return .{ + .device = self.device, + .resource_options = .{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = self.default_storage_mode, + }, + }; +} + +pub const instanceBufferOptions = bufferOptions; +pub const uniformBufferOptions = bufferOptions; +pub const fgBufferOptions = bufferOptions; +pub const bgBufferOptions = bufferOptions; +pub const imageBufferOptions = bufferOptions; +pub const bgImageBufferOptions = bufferOptions; + +/// Returns the options to use when constructing textures. +pub inline fn textureOptions(self: Metal) Texture.Options { + return .{ + .device = self.device, + // Using an `*_srgb` pixel format makes Metal gamma encode the pixels + // written to it *after* blending, which means we get linear alpha + // blending rather than gamma-incorrect blending. + .pixel_format = if (self.blending.isLinear()) + .bgra8unorm_srgb + else + .bgra8unorm, + .resource_options = .{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = self.default_storage_mode, + }, + .usage = .{ + // textureOptions is currently only used for custom shaders, + // which require both the shader read (for when multiple shaders + // are chained) and render target (for the final output) usage. + // Disabling either of these will lead to metal validation + // errors in Xcode. + .shader_read = true, + .render_target = true, + }, + }; +} + +pub inline fn samplerOptions(self: Metal) Sampler.Options { + return .{ + .device = self.device, + + // These parameters match Shadertoy behaviors. + .min_filter = .linear, + .mag_filter = .linear, + .s_address_mode = .clamp_to_edge, + .t_address_mode = .clamp_to_edge, + }; +} + +/// Pixel format for image texture options. +pub const ImageTextureFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 4 bytes per pixel RGBA. + rgba, + /// 4 bytes per pixel BGRA. + bgra, + + fn toPixelFormat( + self: ImageTextureFormat, + srgb: bool, + ) mtl.MTLPixelFormat { + return switch (self) { + .gray => if (srgb) .r8unorm_srgb else .r8unorm, + .rgba => if (srgb) .rgba8unorm_srgb else .rgba8unorm, + .bgra => if (srgb) .bgra8unorm_srgb else .bgra8unorm, + }; + } +}; + +/// Returns the options to use when constructing textures for images. +pub inline fn imageTextureOptions( + self: Metal, + format: ImageTextureFormat, + srgb: bool, +) Texture.Options { + return .{ + .device = self.device, + .pixel_format = format.toPixelFormat(srgb), + .resource_options = .{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = self.default_storage_mode, + }, + .usage = .{ + // We only need to read from this texture from a shader. + .shader_read = true, + }, + }; +} + +/// Initializes a Texture suitable for the provided font atlas. +pub fn initAtlasTexture( + self: *const Metal, + atlas: *const font.Atlas, +) Texture.Error!Texture { + const pixel_format: mtl.MTLPixelFormat = switch (atlas.format) { + .grayscale => .r8unorm, + .bgra => .bgra8unorm_srgb, + else => @panic("unsupported atlas format for Metal texture"), + }; + + return try Texture.init( + .{ + .device = self.device, + .pixel_format = pixel_format, + .resource_options = .{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = self.default_storage_mode, + }, + .usage = .{ + // We only need to read from this texture from a shader. + .shader_read = true, + }, + }, + atlas.size, + atlas.size, + null, + ); +} + +/// Begin a frame. +pub inline fn beginFrame( + self: *const Metal, + /// Once the frame has been completed, the `frameCompleted` method + /// on the renderer is called with the health status of the frame. + renderer: *Renderer, + /// The target is presented via the provided renderer's API when completed. + target: *Target, +) !Frame { + return try Frame.begin(.{ .queue = self.queue }, renderer, target); +} + +fn chooseDevice() error{NoMetalDevice}!objc.Object { + var chosen_device: ?objc.Object = null; + + switch (comptime builtin.os.tag) { + .macos => { + const devices = objc.Object.fromId(mtl.MTLCopyAllDevices()); + defer devices.release(); + + var iter = devices.iterate(); + while (iter.next()) |device| { + // We want a GPU that’s connected to a display. + if (device.getProperty(bool, "isHeadless")) continue; + chosen_device = device; + // If the user has an eGPU plugged in, they probably want + // to use it. Otherwise, integrated GPUs are better for + // battery life and thermals. + if (device.getProperty(bool, "isRemovable") or + device.getProperty(bool, "isLowPower")) break; + } + }, + .ios => { + chosen_device = objc.Object.fromId(mtl.MTLCreateSystemDefaultDevice()); + }, + else => @compileError("unsupported target for Metal"), + } + + const device = chosen_device orelse return error.NoMetalDevice; + return device.retain(); +} + +/// Determines the maximum 2D texture size supported by the device. +/// We need to clamp our frame size to this if it's larger. +fn queryMaxTextureSize(device: objc.Object) u32 { + // https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf + + if (device.msgSend( + bool, + objc.sel("supportsFamily:"), + .{mtl.MTLGPUFamily.apple10}, + )) return 32768; + + if (device.msgSend( + bool, + objc.sel("supportsFamily:"), + .{mtl.MTLGPUFamily.apple3}, + )) return 16384; + + return 8192; +} diff --git a/vendor/libghostty-vt/src/renderer/OpenGL.zig b/vendor/libghostty-vt/src/renderer/OpenGL.zig new file mode 100644 index 00000000..4b01da0c --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/OpenGL.zig @@ -0,0 +1,461 @@ +//! Graphics API wrapper for OpenGL. +pub const OpenGL = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const builtin = @import("builtin"); +const gl = @import("opengl"); +const shadertoy = @import("shadertoy.zig"); +const apprt = @import("../apprt.zig"); +const font = @import("../font/main.zig"); +const configpkg = @import("../config.zig"); +const rendererpkg = @import("../renderer.zig"); +const Renderer = rendererpkg.GenericRenderer(OpenGL); + +pub const GraphicsAPI = OpenGL; +pub const Target = @import("opengl/Target.zig"); +pub const Frame = @import("opengl/Frame.zig"); +pub const RenderPass = @import("opengl/RenderPass.zig"); +pub const Pipeline = @import("opengl/Pipeline.zig"); +const bufferpkg = @import("opengl/buffer.zig"); +pub const Buffer = bufferpkg.Buffer; +pub const Sampler = @import("opengl/Sampler.zig"); +pub const Texture = @import("opengl/Texture.zig"); +pub const shaders = @import("opengl/shaders.zig"); + +pub const custom_shader_target: shadertoy.Target = .glsl; +// The fragCoord for OpenGL shaders is +Y = up. +pub const custom_shader_y_is_down = false; + +/// Because OpenGL's frame completion is always +/// sync, we have no need for multi-buffering. +pub const swap_chain_count = 1; + +const log = std.log.scoped(.opengl); + +/// We require at least OpenGL 4.3 +pub const MIN_VERSION_MAJOR = 4; +pub const MIN_VERSION_MINOR = 3; + +alloc: std.mem.Allocator, + +/// Alpha blending mode +blending: configpkg.Config.AlphaBlending, + +/// The most recently presented target, in case we need to present it again. +last_target: ?Target = null, + +/// NOTE: This is an error{}!OpenGL instead of just OpenGL for parity with +/// Metal, since it needs to be fallible so does this, even though it +/// can't actually fail. +pub fn init(alloc: Allocator, opts: rendererpkg.Options) error{}!OpenGL { + return .{ + .alloc = alloc, + .blending = opts.config.blending, + }; +} + +pub fn deinit(self: *OpenGL) void { + self.* = undefined; +} + +/// 32-bit windows cross-compilation breaks with `.c` for some reason, so... +const gl_debug_proc_callconv = + @typeInfo( + @typeInfo( + @typeInfo( + gl.c.GLDEBUGPROC, + ).optional.child, + ).pointer.child, + ).@"fn".calling_convention; + +fn glDebugMessageCallback( + src: gl.c.GLenum, + typ: gl.c.GLenum, + id: gl.c.GLuint, + severity: gl.c.GLenum, + len: gl.c.GLsizei, + msg: [*c]const gl.c.GLchar, + user_param: ?*const anyopaque, +) callconv(gl_debug_proc_callconv) void { + _ = user_param; + + const src_str: []const u8 = switch (src) { + gl.c.GL_DEBUG_SOURCE_API => "OpenGL API", + gl.c.GL_DEBUG_SOURCE_WINDOW_SYSTEM => "Window System", + gl.c.GL_DEBUG_SOURCE_SHADER_COMPILER => "Shader Compiler", + gl.c.GL_DEBUG_SOURCE_THIRD_PARTY => "Third Party", + gl.c.GL_DEBUG_SOURCE_APPLICATION => "User", + gl.c.GL_DEBUG_SOURCE_OTHER => "Other", + else => "Unknown", + }; + + const typ_str: []const u8 = switch (typ) { + gl.c.GL_DEBUG_TYPE_ERROR => "Error", + gl.c.GL_DEBUG_TYPE_DEPRECATED_BEHAVIOR => "Deprecated Behavior", + gl.c.GL_DEBUG_TYPE_UNDEFINED_BEHAVIOR => "Undefined Behavior", + gl.c.GL_DEBUG_TYPE_PORTABILITY => "Portability Issue", + gl.c.GL_DEBUG_TYPE_PERFORMANCE => "Performance Issue", + gl.c.GL_DEBUG_TYPE_MARKER => "Marker", + gl.c.GL_DEBUG_TYPE_PUSH_GROUP => "Group Push", + gl.c.GL_DEBUG_TYPE_POP_GROUP => "Group Pop", + gl.c.GL_DEBUG_TYPE_OTHER => "Other", + else => "Unknown", + }; + + const msg_str = msg[0..@intCast(len)]; + + (switch (severity) { + gl.c.GL_DEBUG_SEVERITY_HIGH => log.err( + "[{d}] ({s}: {s}) {s}", + .{ id, src_str, typ_str, msg_str }, + ), + gl.c.GL_DEBUG_SEVERITY_MEDIUM => log.warn( + "[{d}] ({s}: {s}) {s}", + .{ id, src_str, typ_str, msg_str }, + ), + gl.c.GL_DEBUG_SEVERITY_LOW => log.info( + "[{d}] ({s}: {s}) {s}", + .{ id, src_str, typ_str, msg_str }, + ), + gl.c.GL_DEBUG_SEVERITY_NOTIFICATION => log.debug( + "[{d}] ({s}: {s}) {s}", + .{ id, src_str, typ_str, msg_str }, + ), + else => log.warn( + "UNKNOWN SEVERITY [{d}] ({s}: {s}) {s}", + .{ id, src_str, typ_str, msg_str }, + ), + }); +} + +/// Prepares the provided GL context, loading it with glad. +fn prepareContext(getProcAddress: anytype) !void { + const version = try gl.glad.load(getProcAddress); + const major = gl.glad.versionMajor(@intCast(version)); + const minor = gl.glad.versionMinor(@intCast(version)); + errdefer gl.glad.unload(); + log.info("loaded OpenGL {}.{}", .{ major, minor }); + + // Need to check version before trying to enable it + if (major < MIN_VERSION_MAJOR or + (major == MIN_VERSION_MAJOR and minor < MIN_VERSION_MINOR)) + { + log.warn( + "OpenGL version is too old. Ghostty requires OpenGL {d}.{d}", + .{ MIN_VERSION_MAJOR, MIN_VERSION_MINOR }, + ); + return error.OpenGLOutdated; + } + + // Enable debug output for the context. + try gl.enable(gl.c.GL_DEBUG_OUTPUT); + + // Register our debug message callback with the OpenGL context. + gl.glad.context.DebugMessageCallback.?(glDebugMessageCallback, null); + + // Enable SRGB framebuffer for linear blending support. + try gl.enable(gl.c.GL_FRAMEBUFFER_SRGB); +} + +/// This is called early right after surface creation. +pub fn surfaceInit(surface: *apprt.Surface) !void { + _ = surface; + + switch (apprt.runtime) { + else => @compileError("unsupported app runtime for OpenGL"), + + // GTK uses global OpenGL context so we load from null. + apprt.gtk, + => try prepareContext(null), + + apprt.embedded => { + // TODO(mitchellh): this does nothing today to allow libghostty + // to compile for OpenGL targets but libghostty is strictly + // broken for rendering on this platforms. + }, + } + + // These are very noisy so this is commented, but easy to uncomment + // whenever we need to check the OpenGL extension list + // if (builtin.mode == .Debug) { + // var ext_iter = try gl.ext.iterator(); + // while (try ext_iter.next()) |ext| { + // log.debug("OpenGL extension available name={s}", .{ext}); + // } + // } +} + +/// This is called just prior to spinning up the renderer +/// thread for final main thread setup requirements. +pub fn finalizeSurfaceInit(self: *const OpenGL, surface: *apprt.Surface) !void { + _ = self; + _ = surface; +} + +/// Callback called by renderer.Thread when it begins. +pub fn threadEnter(self: *const OpenGL, surface: *apprt.Surface) !void { + _ = self; + _ = surface; + + switch (apprt.runtime) { + else => @compileError("unsupported app runtime for OpenGL"), + + apprt.gtk => { + // GTK doesn't support threaded OpenGL operations as far as I can + // tell, so we use the renderer thread to setup all the state + // but then do the actual draws and texture syncs and all that + // on the main thread. As such, we don't do anything here. + }, + + apprt.embedded => { + // TODO(mitchellh): this does nothing today to allow libghostty + // to compile for OpenGL targets but libghostty is strictly + // broken for rendering on this platforms. + }, + } +} + +/// Callback called by renderer.Thread when it exits. +pub fn threadExit(self: *const OpenGL) void { + _ = self; + + switch (apprt.runtime) { + else => @compileError("unsupported app runtime for OpenGL"), + + apprt.gtk => { + // We don't need to do any unloading for GTK because we may + // be sharing the global bindings with other windows. + }, + + apprt.embedded => { + // TODO: see threadEnter + }, + } +} + +pub fn displayRealized(self: *const OpenGL) void { + _ = self; + + switch (apprt.runtime) { + apprt.gtk => prepareContext(null) catch |err| { + log.warn( + "Error preparing GL context in displayRealized, err={}", + .{err}, + ); + }, + + else => @compileError("only GTK should be calling displayRealized"), + } +} + +/// Actions taken before doing anything in `drawFrame`. +/// +/// Right now there's nothing we need to do for OpenGL. +pub fn drawFrameStart(self: *OpenGL) void { + _ = self; +} + +/// Actions taken after `drawFrame` is done. +/// +/// Right now there's nothing we need to do for OpenGL. +pub fn drawFrameEnd(self: *OpenGL) void { + _ = self; +} + +pub fn initShaders( + self: *const OpenGL, + alloc: Allocator, + custom_shaders: []const [:0]const u8, +) !shaders.Shaders { + _ = alloc; + return try shaders.Shaders.init( + self.alloc, + custom_shaders, + ); +} + +/// Get the current size of the runtime surface. +pub fn surfaceSize(self: *const OpenGL) !struct { width: u32, height: u32 } { + _ = self; + var viewport: [4]gl.c.GLint = undefined; + gl.glad.context.GetIntegerv.?(gl.c.GL_VIEWPORT, &viewport); + return .{ + .width = @intCast(viewport[2]), + .height = @intCast(viewport[3]), + }; +} + +/// Initialize a new render target which can be presented by this API. +pub fn initTarget(self: *const OpenGL, width: usize, height: usize) !Target { + return Target.init(.{ + .internal_format = if (self.blending.isLinear()) .srgba else .rgba, + .width = width, + .height = height, + }); +} + +/// Present the provided target. +pub fn present(self: *OpenGL, target: Target) !void { + // In order to present a target we blit it to the default framebuffer. + + // We disable GL_FRAMEBUFFER_SRGB while doing this blit, otherwise the + // values may be linearized as they're copied, but even though the draw + // framebuffer has a linear internal format, the values in it should be + // sRGB, not linear! + try gl.disable(gl.c.GL_FRAMEBUFFER_SRGB); + defer gl.enable(gl.c.GL_FRAMEBUFFER_SRGB) catch |err| { + log.err("Error re-enabling GL_FRAMEBUFFER_SRGB, err={}", .{err}); + }; + + // Bind the target for reading. + const fbobind = try target.framebuffer.bind(.read); + defer fbobind.unbind(); + + // Blit + gl.glad.context.BlitFramebuffer.?( + 0, + 0, + @intCast(target.width), + @intCast(target.height), + 0, + 0, + @intCast(target.width), + @intCast(target.height), + gl.c.GL_COLOR_BUFFER_BIT, + gl.c.GL_NEAREST, + ); + + // Keep track of this target in case we need to repeat it. + self.last_target = target; +} + +/// Present the last presented target again. +pub fn presentLastTarget(self: *OpenGL) !void { + if (self.last_target) |target| try self.present(target); +} + +/// Returns the options to use when constructing buffers. +pub inline fn bufferOptions(self: OpenGL) bufferpkg.Options { + _ = self; + return .{ + .target = .array, + .usage = .dynamic_draw, + }; +} + +pub const instanceBufferOptions = bufferOptions; +pub const uniformBufferOptions = bufferOptions; +pub const fgBufferOptions = bufferOptions; +pub const bgBufferOptions = bufferOptions; +pub const imageBufferOptions = bufferOptions; +pub const bgImageBufferOptions = bufferOptions; + +/// Returns the options to use when constructing textures. +pub inline fn textureOptions(self: OpenGL) Texture.Options { + _ = self; + return .{ + .format = .rgba, + .internal_format = .srgba, + .target = .@"2D", + .min_filter = .linear, + .mag_filter = .linear, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + }; +} + +/// Returns the options to use when constructing samplers. +pub inline fn samplerOptions(self: OpenGL) Sampler.Options { + _ = self; + return .{ + .min_filter = .linear, + .mag_filter = .linear, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + }; +} + +/// Pixel format for image texture options. +pub const ImageTextureFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 4 bytes per pixel RGBA. + rgba, + /// 4 bytes per pixel BGRA. + bgra, + + fn toPixelFormat(self: ImageTextureFormat) gl.Texture.Format { + return switch (self) { + .gray => .red, + .rgba => .rgba, + .bgra => .bgra, + }; + } +}; + +/// Returns the options to use when constructing textures for images. +pub inline fn imageTextureOptions( + self: OpenGL, + format: ImageTextureFormat, + srgb: bool, +) Texture.Options { + _ = self; + return .{ + .format = format.toPixelFormat(), + .internal_format = if (srgb) .srgba else .rgba, + .target = .@"2D", + // TODO: Generate mipmaps for image textures and use + // linear_mipmap_linear filtering so that they + // look good even when scaled way down. + .min_filter = .linear, + .mag_filter = .linear, + // TODO: Separate out background image options, use + // repeating coordinate modes so we don't have + // to do the modulus in the shader. + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + }; +} + +/// Initializes a Texture suitable for the provided font atlas. +pub fn initAtlasTexture( + self: *const OpenGL, + atlas: *const font.Atlas, +) Texture.Error!Texture { + _ = self; + const format: gl.Texture.Format, const internal_format: gl.Texture.InternalFormat = + switch (atlas.format) { + .grayscale => .{ .red, .red }, + .bgra => .{ .bgra, .srgba }, + else => @panic("unsupported atlas format for OpenGL texture"), + }; + + return try Texture.init( + .{ + .format = format, + .internal_format = internal_format, + .target = .Rectangle, + .min_filter = .nearest, + .mag_filter = .nearest, + .wrap_s = .clamp_to_edge, + .wrap_t = .clamp_to_edge, + }, + atlas.size, + atlas.size, + null, + ); +} + +/// Begin a frame. +pub inline fn beginFrame( + self: *const OpenGL, + /// Once the frame has been completed, the `frameCompleted` method + /// on the renderer is called with the health status of the frame. + renderer: *Renderer, + /// The target is presented via the provided renderer's API when completed. + target: *Target, +) !Frame { + _ = self; + return try Frame.begin(.{}, renderer, target); +} diff --git a/vendor/libghostty-vt/src/renderer/Options.zig b/vendor/libghostty-vt/src/renderer/Options.zig new file mode 100644 index 00000000..948b31d2 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/Options.zig @@ -0,0 +1,24 @@ +//! The options that are used to configure a renderer. + +const apprt = @import("../apprt.zig"); +const font = @import("../font/main.zig"); +const renderer = @import("../renderer.zig"); + +/// The derived configuration for this renderer implementation. +config: renderer.Renderer.DerivedConfig, + +/// The font grid that should be used along with the key for deref-ing. +font_grid: *font.SharedGrid, + +/// The size of everything. +size: renderer.Size, + +/// The mailbox for sending the surface messages. This is only valid +/// once the thread has started and should not be used outside of the thread. +surface_mailbox: apprt.surface.Mailbox, + +/// The apprt surface. +rt_surface: *apprt.Surface, + +/// The renderer thread. +thread: *renderer.Thread, diff --git a/vendor/libghostty-vt/src/renderer/Overlay.zig b/vendor/libghostty-vt/src/renderer/Overlay.zig new file mode 100644 index 00000000..62eb004b --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/Overlay.zig @@ -0,0 +1,415 @@ +/// The debug overlay that can be drawn on top of the terminal +/// during the rendering process. +/// +/// This is implemented by doing all the drawing on the CPU via z2d, +/// since the debug overlay isn't that common, z2d is pretty fast, and +/// it simplifies our implementation quite a bit by not relying on us +/// having a bunch of shaders that we have to write per-platform. +/// +/// Initialize the overlay, apply features with `applyFeatures`, then +/// get the resulting image with `pendingImage` to upload to the GPU. +/// This works in concert with `renderer.image.State` to simplify. Draw +/// it on the GPU as an image composited on top of the terminal output. +const Overlay = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const z2d = @import("z2d"); +const terminal = @import("../terminal/main.zig"); +const size = @import("size.zig"); +const Size = size.Size; +const CellSize = size.CellSize; +const Image = @import("image.zig").Image; + +const log = std.log.scoped(.renderer_overlay); + +/// The colors we use for overlays. +pub const Color = enum { + hyperlink, // light blue + semantic_prompt, // orange/gold + semantic_input, // cyan + + pub fn rgb(self: Color) z2d.pixel.RGB { + return switch (self) { + .hyperlink => .{ .r = 180, .g = 180, .b = 255 }, + .semantic_prompt => .{ .r = 255, .g = 200, .b = 64 }, + .semantic_input => .{ .r = 64, .g = 200, .b = 255 }, + }; + } + + /// The fill color for rectangles. + pub fn rectFill(self: Color) z2d.Pixel { + return self.alphaPixel(96); + } + + /// The border color for rectangles. + pub fn rectBorder(self: Color) z2d.Pixel { + return self.alphaPixel(200); + } + + /// The raw RGB as a pixel. + pub fn pixel(self: Color) z2d.Pixel { + return self.rgb().asPixel(); + } + + fn alphaPixel(self: Color, alpha: u8) z2d.Pixel { + var rgba: z2d.pixel.RGBA = .fromPixel(self.pixel()); + rgba.a = alpha; + return rgba.multiply().asPixel(); + } +}; + +/// The surface we're drawing our overlay to. +surface: z2d.Surface, + +/// Cell size information so we can map grid coordinates to pixels. +cell_size: CellSize, + +/// The set of available features and their configuration. +pub const Feature = union(enum) { + highlight_hyperlinks, + semantic_prompts, +}; + +pub const InitError = Allocator.Error || error{ + // The terminal dimensions are invalid to support an overlay. + // Either too small or too big. + InvalidDimensions, +}; + +/// Initialize a new, blank overlay. +pub fn init(alloc: Allocator, sz: Size) InitError!Overlay { + // Our surface does NOT need to take into account padding because + // we render the overlay using the image subsystem and shaders which + // already take that into account. + const term_size = sz.terminal(); + var sfc = z2d.Surface.initPixel( + .{ .rgba = .{ .r = 0, .g = 0, .b = 0, .a = 0 } }, + alloc, + std.math.cast(i32, term_size.width) orelse + return error.InvalidDimensions, + std.math.cast(i32, term_size.height) orelse + return error.InvalidDimensions, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidWidth, error.InvalidHeight => return error.InvalidDimensions, + }; + errdefer sfc.deinit(alloc); + + return .{ + .surface = sfc, + .cell_size = sz.cell, + }; +} + +pub fn deinit(self: *Overlay, alloc: Allocator) void { + self.surface.deinit(alloc); +} + +/// Returns a pending image that can be used to copy, convert, upload, etc. +pub fn pendingImage(self: *const Overlay) Image.Pending { + return .{ + .width = @intCast(self.surface.getWidth()), + .height = @intCast(self.surface.getHeight()), + .pixel_format = .rgba, + .data = @ptrCast(self.surface.image_surface_rgba.buf.ptr), + }; +} + +/// Clear the overlay. +pub fn reset(self: *Overlay) void { + self.surface.paintPixel(.{ .rgba = .{ + .r = 0, + .g = 0, + .b = 0, + .a = 0, + } }); +} + +/// Apply the given features to this overlay. This will draw on top of +/// any pre-existing content in the overlay. +pub fn applyFeatures( + self: *Overlay, + alloc: Allocator, + state: *const terminal.RenderState, + features: []const Feature, +) void { + for (features) |f| switch (f) { + .highlight_hyperlinks => self.highlightHyperlinks( + alloc, + state, + ), + .semantic_prompts => self.highlightSemanticPrompts( + alloc, + state, + ), + }; +} + +/// Add rectangles around contiguous hyperlinks in the render state. +/// +/// Note: this currently doesn't take into account unique hyperlink IDs +/// because the render state doesn't contain this. This will be added +/// later. +fn highlightHyperlinks( + self: *Overlay, + alloc: Allocator, + state: *const terminal.RenderState, +) void { + const border_color = Color.hyperlink.rectBorder(); + const fill_color = Color.hyperlink.rectFill(); + + const row_slice = state.row_data.slice(); + const row_raw = row_slice.items(.raw); + const row_cells = row_slice.items(.cells); + for (row_raw, row_cells, 0..) |row, cells, y| { + if (!row.hyperlink) continue; + + const cells_slice = cells.slice(); + const raw_cells = cells_slice.items(.raw); + + var x: usize = 0; + while (x < raw_cells.len) { + // Skip cells without hyperlinks + if (!raw_cells[x].hyperlink) { + x += 1; + continue; + } + + // Found start of a hyperlink run + const start_x = x; + + // Find end of contiguous hyperlink cells + while (x < raw_cells.len and raw_cells[x].hyperlink) x += 1; + const end_x = x; + + self.highlightGridRect( + alloc, + start_x, + y, + end_x - start_x, + 1, + border_color, + fill_color, + ) catch |err| { + std.log.warn("Error drawing hyperlink border: {}", .{err}); + }; + } + } +} + +fn highlightSemanticPrompts( + self: *Overlay, + alloc: Allocator, + state: *const terminal.RenderState, +) void { + const row_slice = state.row_data.slice(); + const row_raw = row_slice.items(.raw); + const row_cells = row_slice.items(.cells); + + // Highlight the row-level semantic prompt bars. The prompts are easy + // because they're part of the row metadata. + { + const prompt_border = Color.semantic_prompt.rectBorder(); + const prompt_fill = Color.semantic_prompt.rectFill(); + + var y: usize = 0; + while (y < row_raw.len) { + // If its not a semantic prompt row, skip it. + if (row_raw[y].semantic_prompt == .none) { + y += 1; + continue; + } + + // Find the full length of the semantic prompt row by connecting + // all continuations. + const start_y = y; + y += 1; + while (y < row_raw.len and + row_raw[y].semantic_prompt == .prompt_continuation) + { + y += 1; + } + const end_y = y; // Exclusive + + const bar_width = @min(@as(usize, 5), self.cell_size.width); + self.highlightPixelRect( + alloc, + 0, + start_y, + bar_width, + end_y - start_y, + prompt_border, + prompt_fill, + ) catch |err| { + log.warn("Error drawing semantic prompt bar: {}", .{err}); + }; + } + } + + // Highlight contiguous semantic cells within rows. + for (row_cells, 0..) |cells, y| { + const cells_slice = cells.slice(); + const raw_cells = cells_slice.items(.raw); + + var x: usize = 0; + while (x < raw_cells.len) { + const cell = raw_cells[x]; + const content = cell.semantic_content; + const start_x = x; + + // We skip output because its just the rest of the non-prompt + // parts and it makes the overlay too noisy. + if (cell.semantic_content == .output) { + x += 1; + continue; + } + + // Find the end of this content. + x += 1; + while (x < raw_cells.len) { + const next = raw_cells[x]; + if (next.semantic_content != content) break; + x += 1; + } + + const color: Color = switch (content) { + .prompt => .semantic_prompt, + .input => .semantic_input, + .output => unreachable, + }; + + self.highlightGridRect( + alloc, + start_x, + y, + x - start_x, + 1, + color.rectBorder(), + color.rectFill(), + ) catch |err| { + log.warn("Error drawing semantic content highlight: {}", .{err}); + }; + } + } +} + +/// Creates a rectangle for highlighting a grid region. x/y/width/height +/// are all in grid cells. +fn highlightGridRect( + self: *Overlay, + alloc: Allocator, + x: usize, + y: usize, + width: usize, + height: usize, + border_color: z2d.Pixel, + fill_color: z2d.Pixel, +) !void { + // All math below uses checked arithmetic to avoid overflows. The + // inputs aren't trusted and the path this is in isn't hot enough + // to wrarrant unsafe optimizations. + + // Calculate our width/height in pixels. + const px_width = std.math.cast(i32, try std.math.mul( + usize, + width, + self.cell_size.width, + )) orelse return error.Overflow; + const px_height = std.math.cast(i32, try std.math.mul( + usize, + height, + self.cell_size.height, + )) orelse return error.Overflow; + + // Calculate pixel coordinates + const start_x: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + x, + self.cell_size.width, + )) orelse return error.Overflow); + const start_y: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + y, + self.cell_size.height, + )) orelse return error.Overflow); + const end_x: f64 = start_x + @as(f64, @floatFromInt(px_width)); + const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height)); + + // Grab our context to draw + var ctx: z2d.Context = .init(alloc, &self.surface); + defer ctx.deinit(); + + // Don't need AA because we use sharp edges + ctx.setAntiAliasingMode(.none); + // Can use hairline since we have 1px borders + ctx.setHairline(true); + + // Draw rectangle path + try ctx.moveTo(start_x, start_y); + try ctx.lineTo(end_x, start_y); + try ctx.lineTo(end_x, end_y); + try ctx.lineTo(start_x, end_y); + try ctx.closePath(); + + // Fill + ctx.setSourceToPixel(fill_color); + try ctx.fill(); + + // Border + ctx.setLineWidth(1); + ctx.setSourceToPixel(border_color); + try ctx.stroke(); +} + +/// Creates a rectangle for highlighting a region. x/y are grid cells and +/// width/height are pixels. +fn highlightPixelRect( + self: *Overlay, + alloc: Allocator, + x: usize, + y: usize, + width_px: usize, + height: usize, + border_color: z2d.Pixel, + fill_color: z2d.Pixel, +) !void { + const px_width = std.math.cast(i32, width_px) orelse return error.Overflow; + const px_height = std.math.cast(i32, try std.math.mul( + usize, + height, + self.cell_size.height, + )) orelse return error.Overflow; + + const start_x: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + x, + self.cell_size.width, + )) orelse return error.Overflow); + const start_y: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + y, + self.cell_size.height, + )) orelse return error.Overflow); + const end_x: f64 = start_x + @as(f64, @floatFromInt(px_width)); + const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height)); + + var ctx: z2d.Context = .init(alloc, &self.surface); + defer ctx.deinit(); + + ctx.setAntiAliasingMode(.none); + ctx.setHairline(true); + + try ctx.moveTo(start_x, start_y); + try ctx.lineTo(end_x, start_y); + try ctx.lineTo(end_x, end_y); + try ctx.lineTo(start_x, end_y); + try ctx.closePath(); + + ctx.setSourceToPixel(fill_color); + try ctx.fill(); + + ctx.setLineWidth(1); + ctx.setSourceToPixel(border_color); + try ctx.stroke(); +} diff --git a/vendor/libghostty-vt/src/renderer/State.zig b/vendor/libghostty-vt/src/renderer/State.zig new file mode 100644 index 00000000..e882ddce --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/State.zig @@ -0,0 +1,123 @@ +//! This is the render state that is given to a renderer. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Inspector = @import("../inspector/main.zig").Inspector; +const terminalpkg = @import("../terminal/main.zig"); +const inputpkg = @import("../input.zig"); +const renderer = @import("../renderer.zig"); + +/// The mutex that must be held while reading any of the data in the +/// members of this state. Note that the state itself is NOT protected +/// by the mutex and is NOT thread-safe, only the members values of the +/// state (i.e. the terminal, devmode, etc. values). +mutex: *std.Thread.Mutex, + +/// The terminal data. +terminal: *terminalpkg.Terminal, + +/// The terminal inspector, if any. This will be null while the inspector +/// is not active and will be set when it is active. +inspector: ?*Inspector = null, + +/// Dead key state. This will render the current dead key preedit text +/// over the cursor. This currently only ever renders a single codepoint. +/// Preedit can in theory be multiple codepoints long but that is left as +/// a future exercise. +preedit: ?Preedit = null, + +/// Mouse state. This only contains state relevant to what renderers +/// need about the mouse. +mouse: Mouse = .{}, + +pub const Mouse = struct { + /// The point on the viewport where the mouse currently is. We use + /// viewport points to avoid the complexity of mapping the mouse to + /// the renderer state. + point: ?terminalpkg.point.Coordinate = null, + + /// The mods that are currently active for the last mouse event. + /// This could really just be mods in general and we probably will + /// move it out of mouse state at some point. + mods: inputpkg.Mods = .{}, +}; + +/// The pre-edit state. See Surface.preeditCallback for more information. +pub const Preedit = struct { + /// The codepoints to render as preedit text. + codepoints: []const Codepoint = &.{}, + + /// A single codepoint to render as preedit text. + pub const Codepoint = struct { + codepoint: u21, + wide: bool = false, + }; + + /// Deinit this preedit that was cre + pub fn deinit(self: *const Preedit, alloc: Allocator) void { + alloc.free(self.codepoints); + } + + /// Allocate a copy of this preedit in the given allocator.. + pub fn clone(self: *const Preedit, alloc: Allocator) !Preedit { + return .{ + .codepoints = try alloc.dupe(Codepoint, self.codepoints), + }; + } + + /// The width in cells of all codepoints in the preedit. + pub fn width(self: *const Preedit) usize { + var result: usize = 0; + for (self.codepoints) |cp| { + result += if (cp.wide) 2 else 1; + } + + return result; + } + + /// Range returns the start and end x position of the preedit text + /// along with any codepoint offset necessary to fit the preedit + /// into the available space. + pub fn range( + self: *const Preedit, + start: terminalpkg.size.CellCountInt, + max: terminalpkg.size.CellCountInt, + ) struct { + start: terminalpkg.size.CellCountInt, + end: terminalpkg.size.CellCountInt, + cp_offset: usize, + } { + // If our width is greater than the number of cells we have + // then we need to adjust our codepoint start to a point where + // our width would be less than the number of cells we have. + const w, const cp_offset = width: { + // max is inclusive, so we need to add 1 to it. + const max_width = max - start + 1; + + // Rebuild our width in reverse order. This is because we want + // to offset by the end cells, not the start cells (if we have to). + var w: terminalpkg.size.CellCountInt = 0; + for (0..self.codepoints.len) |i| { + const reverse_i = self.codepoints.len - i - 1; + const cp = self.codepoints[reverse_i]; + w += if (cp.wide) 2 else 1; + if (w > max_width) { + break :width .{ w, reverse_i }; + } + } + + // Width fit in the max width so no offset necessary. + break :width .{ w, 0 }; + }; + + // If our preedit goes off the end of the screen, we adjust it so + // that it shifts left. + const end = start + w; + const start_offset = if (end > max) end - max else 0; + return .{ + .start = start -| start_offset, + .end = end -| start_offset, + .cp_offset = cp_offset, + }; + } +}; diff --git a/vendor/libghostty-vt/src/renderer/Thread.zig b/vendor/libghostty-vt/src/renderer/Thread.zig new file mode 100644 index 00000000..50872137 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/Thread.zig @@ -0,0 +1,715 @@ +//! Represents the renderer thread logic. The renderer thread is able to +//! be woken up to render. +pub const Thread = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const xev = @import("../global.zig").xev; +const crash = @import("../crash/main.zig"); +const internal_os = @import("../os/main.zig"); +const rendererpkg = @import("../renderer.zig"); +const apprt = @import("../apprt.zig"); +const configpkg = @import("../config.zig"); +const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue; +const App = @import("../App.zig"); + +const Allocator = std.mem.Allocator; +const log = std.log.scoped(.renderer_thread); + +const DRAW_INTERVAL = 8; // 120 FPS +const CURSOR_BLINK_INTERVAL = 600; + +/// Whether calls to `drawFrame` must be done from the app thread. +/// +/// If this is `true` then we send a `redraw_surface` message to the apprt +/// whenever we need to draw instead of calling `drawFrame` directly. +const must_draw_from_app_thread = + if (@hasDecl(apprt.App, "must_draw_from_app_thread")) + apprt.App.must_draw_from_app_thread + else + false; + +/// The type used for sending messages to the IO thread. For now this is +/// hardcoded with a capacity. We can make this a comptime parameter in +/// the future if we want it configurable. +pub const Mailbox = BlockingQueue(rendererpkg.Message, 64); + +/// Allocator used for some state +alloc: std.mem.Allocator, + +/// The main event loop for the application. The user data of this loop +/// is always the allocator used to create the loop. This is a convenience +/// so that users of the loop always have an allocator. +loop: xev.Loop, + +/// This can be used to wake up the renderer and force a render safely from +/// any thread. +wakeup: xev.Async, +wakeup_c: xev.Completion = .{}, + +/// This can be used to stop the renderer on the next loop iteration. +stop: xev.Async, +stop_c: xev.Completion = .{}, + +/// The timer used for rendering +render_h: xev.Timer, +render_c: xev.Completion = .{}, + +/// The timer used for draw calls. Draw calls don't update from the +/// terminal state so they're much cheaper. They're used for animation +/// and are paused when the terminal is not focused. +draw_h: xev.Timer, +draw_c: xev.Completion = .{}, +draw_active: bool = false, + +/// This async is used to force a draw immediately. This does not +/// coalesce like the wakeup does. +draw_now: xev.Async, +draw_now_c: xev.Completion = .{}, + +/// The timer used for cursor blinking +cursor_h: xev.Timer, +cursor_c: xev.Completion = .{}, +cursor_c_cancel: xev.Completion = .{}, + +/// The surface we're rendering to. +surface: *apprt.Surface, + +/// The underlying renderer implementation. +renderer: *rendererpkg.Renderer, + +/// Pointer to the shared state that is used to generate the final render. +state: *rendererpkg.State, + +/// The mailbox that can be used to send this thread messages. Note +/// this is a blocking queue so if it is full you will get errors (or block). +mailbox: *Mailbox, + +/// Mailbox to send messages to the app thread +app_mailbox: App.Mailbox, + +/// Configuration we need derived from the main config. +config: DerivedConfig, + +flags: packed struct { + /// This is true when a blinking cursor should be visible and false + /// when it should not be visible. This is toggled on a timer by the + /// thread automatically. + cursor_blink_visible: bool = false, + + /// This is true when the inspector is active. + has_inspector: bool = false, + + /// This is true when the view is visible. This is used to determine + /// if we should be rendering or not. + visible: bool = true, + + /// This is true when the view is focused. This defaults to true + /// and it is up to the apprt to set the correct value. + focused: bool = true, +} = .{}, + +pub const DerivedConfig = struct { + custom_shader_animation: configpkg.CustomShaderAnimation, + + pub fn init(config: *const configpkg.Config) DerivedConfig { + return .{ + .custom_shader_animation = config.@"custom-shader-animation", + }; + } +}; + +/// Initialize the thread. This does not START the thread. This only sets +/// up all the internal state necessary prior to starting the thread. It +/// is up to the caller to start the thread with the threadMain entrypoint. +pub fn init( + alloc: Allocator, + config: *const configpkg.Config, + surface: *apprt.Surface, + renderer_impl: *rendererpkg.Renderer, + state: *rendererpkg.State, + app_mailbox: App.Mailbox, +) !Thread { + // Create our event loop. + var loop = try xev.Loop.init(.{}); + errdefer loop.deinit(); + + // This async handle is used to "wake up" the renderer and force a render. + var wakeup_h = try xev.Async.init(); + errdefer wakeup_h.deinit(); + + // This async handle is used to stop the loop and force the thread to end. + var stop_h = try xev.Async.init(); + errdefer stop_h.deinit(); + + // The primary timer for rendering. + var render_h = try xev.Timer.init(); + errdefer render_h.deinit(); + + // Draw timer, see comments. + var draw_h = try xev.Timer.init(); + errdefer draw_h.deinit(); + + // Draw now async, see comments. + var draw_now = try xev.Async.init(); + errdefer draw_now.deinit(); + + // Setup a timer for blinking the cursor + var cursor_timer = try xev.Timer.init(); + errdefer cursor_timer.deinit(); + + // The mailbox for messaging this thread + var mailbox = try Mailbox.create(alloc); + errdefer mailbox.destroy(alloc); + + return .{ + .alloc = alloc, + .config = .init(config), + .loop = loop, + .wakeup = wakeup_h, + .stop = stop_h, + .render_h = render_h, + .draw_h = draw_h, + .draw_now = draw_now, + .cursor_h = cursor_timer, + .surface = surface, + .renderer = renderer_impl, + .state = state, + .mailbox = mailbox, + .app_mailbox = app_mailbox, + }; +} + +/// Clean up the thread. This is only safe to call once the thread +/// completes executing; the caller must join prior to this. +pub fn deinit(self: *Thread) void { + self.stop.deinit(); + self.wakeup.deinit(); + self.render_h.deinit(); + self.draw_h.deinit(); + self.draw_now.deinit(); + self.cursor_h.deinit(); + self.loop.deinit(); + + // Nothing can possibly access the mailbox anymore, destroy it. + self.mailbox.destroy(self.alloc); +} + +/// The main entrypoint for the thread. +pub fn threadMain(self: *Thread) void { + // Call child function so we can use errors... + self.threadMain_() catch |err| { + // In the future, we should expose this on the thread struct. + log.warn("error in renderer err={}", .{err}); + }; +} + +fn threadMain_(self: *Thread) !void { + defer log.debug("renderer thread exited", .{}); + + // Right now, on Darwin, `std.Thread.setName` can only name the current + // thread, and we have no way to get the current thread from within it, + // so instead we use this code to name the thread instead. + if (builtin.os.tag.isDarwin()) { + internal_os.macos.pthread_setname_np(&"renderer".*); + } + + // Setup our crash metadata + crash.sentry.thread_state = .{ + .type = .renderer, + .surface = self.renderer.surface_mailbox.surface, + }; + defer crash.sentry.thread_state = null; + + // Setup our thread QoS + self.setQosClass(); + + // Run our loop start/end callbacks if the renderer cares. + const has_loop = @hasDecl(rendererpkg.Renderer, "loopEnter"); + if (has_loop) try self.renderer.loopEnter(self); + defer if (has_loop) self.renderer.loopExit(); + + // Run our thread start/end callbacks. This is important because some + // renderers have to do per-thread setup. For example, OpenGL has to set + // some thread-local state since that is how it works. + try self.renderer.threadEnter(self.surface); + defer self.renderer.threadExit(); + + // Start the async handlers + self.wakeup.wait(&self.loop, &self.wakeup_c, Thread, self, wakeupCallback); + self.stop.wait(&self.loop, &self.stop_c, Thread, self, stopCallback); + self.draw_now.wait(&self.loop, &self.draw_now_c, Thread, self, drawNowCallback); + + // Send an initial wakeup message so that we render right away. + try self.wakeup.notify(); + + // Start blinking the cursor. + self.cursor_h.run( + &self.loop, + &self.cursor_c, + cursorBlinkInterval(), + Thread, + self, + cursorTimerCallback, + ); + + // Start the draw timer + self.syncDrawTimer(); + + // Run + log.debug("starting renderer thread", .{}); + defer log.debug("starting renderer thread shutdown", .{}); + _ = try self.loop.run(.until_done); +} + +fn setQosClass(self: *const Thread) void { + // Thread QoS classes are only relevant on macOS. + if (comptime !builtin.target.os.tag.isDarwin()) return; + + const class: internal_os.macos.QosClass = class: { + // If we aren't visible (our view is fully occluded) then we + // always drop our rendering priority down because it's just + // mostly wasted work. + // + // The renderer itself should be doing this as well (for example + // Metal will stop our DisplayLink) but this also helps with + // general forced updates and CPU usage i.e. a rebuild cells call. + if (!self.flags.visible) break :class .utility; + + // If we're not focused, but we're visible, then we set a higher + // than default priority because framerates still matter but it isn't + // as important as when we're focused. + if (!self.flags.focused) break :class .user_initiated; + + // We are focused and visible, we are the definition of user interactive. + break :class .user_interactive; + }; + + if (internal_os.macos.setQosClass(class)) { + log.debug("thread QoS class set class={}", .{class}); + } else |err| { + log.warn("error setting QoS class err={}", .{err}); + } +} + +fn syncDrawTimer(self: *Thread) void { + skip: { + // If our renderer supports animations and has them, then we + // can apply draw timer based on custom shader animation configuration. + if (@hasDecl(rendererpkg.Renderer, "hasAnimations") and + self.renderer.hasAnimations()) + { + // If our config says to always animate, we do so. + switch (self.config.custom_shader_animation) { + // Always animate + .always => break :skip, + // Only when focused + .true => if (self.flags.focused) break :skip, + // Never animate + .false => {}, + } + } + + // We're skipping the draw timer. Stop it on the next iteration. + self.draw_active = false; + return; + } + + // Set our active state so it knows we're running. We set this before + // even checking the active state in case we have a pending shutdown. + self.draw_active = true; + + // If our draw timer is already active, then we don't have to do anything. + if (self.draw_c.state() == .active) return; + + // Start the timer which loops + self.draw_h.run( + &self.loop, + &self.draw_c, + DRAW_INTERVAL, + Thread, + self, + drawCallback, + ); +} + +/// Drain the mailbox. +fn drainMailbox(self: *Thread) !void { + // There's probably a more elegant way to do this... + // + // This is effectively an @autoreleasepool{} block, which we need in + // order to ensure that autoreleased objects are properly released. + const pool = if (builtin.os.tag.isDarwin()) + @import("objc").AutoreleasePool.init() + else + void; + defer if (builtin.os.tag.isDarwin()) pool.deinit(); + + while (self.mailbox.pop()) |message| { + log.debug("mailbox message={}", .{message}); + switch (message) { + .crash => @panic("crash request, crashing intentionally"), + + .visible => |v| visible: { + // If our state didn't change we do nothing. + if (self.flags.visible == v) break :visible; + + // Set our visible state + self.flags.visible = v; + + // Visibility affects our QoS class + self.setQosClass(); + + // If we became visible then we immediately trigger a draw. + // We don't need to update frame data because that should + // still be happening. + if (v) self.drawFrame(false); + + // Notify the renderer so it can update any state. + self.renderer.setVisible(v); + + // Note that we're explicitly today not stopping any + // cursor timers, draw timers, etc. These things have very + // little resource cost and properly maintaining their active + // state across different transitions is going to be bug-prone, + // so its easier to just let them keep firing and have them + // check the visible state themselves to control their behavior. + }, + + .focus => |v| focus: { + // If our state didn't change we do nothing. + if (self.flags.focused == v) break :focus; + + // Set our state + self.flags.focused = v; + + // Focus affects our QoS class + self.setQosClass(); + + // Set it on the renderer + try self.renderer.setFocus(v); + + // We always resync our draw timer (may disable it) + self.syncDrawTimer(); + + if (!v) { + // If we're not focused, then we stop the cursor blink + if (self.cursor_c.state() == .active and + self.cursor_c_cancel.state() == .dead) + { + self.cursor_h.cancel( + &self.loop, + &self.cursor_c, + &self.cursor_c_cancel, + void, + null, + cursorCancelCallback, + ); + } + } else { + // If we're focused, we immediately show the cursor again + // and then restart the timer. + if (self.cursor_c.state() != .active) { + self.flags.cursor_blink_visible = true; + self.cursor_h.run( + &self.loop, + &self.cursor_c, + cursorBlinkInterval(), + Thread, + self, + cursorTimerCallback, + ); + } + } + }, + + .reset_cursor_blink => { + self.flags.cursor_blink_visible = true; + if (self.cursor_c.state() == .active) { + self.cursor_h.reset( + &self.loop, + &self.cursor_c, + &self.cursor_c_cancel, + cursorBlinkInterval(), + Thread, + self, + cursorTimerCallback, + ); + } + }, + + .font_grid => |grid| { + self.renderer.setFontGrid(grid.grid); + grid.set.deref(grid.old_key); + }, + + .resize => |v| self.renderer.setScreenSize(v), + + .change_config => |config| { + defer config.alloc.destroy(config.thread); + defer config.alloc.destroy(config.impl); + try self.changeConfig(config.thread); + try self.renderer.changeConfig(config.impl); + + // Stop and start the draw timer to capture the new + // hasAnimations value. + self.syncDrawTimer(); + }, + + .search_viewport_matches => |v| { + // Note we don't free the new value because we expect our + // allocators to match. + if (self.renderer.search_matches) |*m| m.arena.deinit(); + self.renderer.search_matches = v; + self.renderer.search_matches_dirty = true; + }, + + .search_selected_match => |v| { + // Note we don't free the new value because we expect our + // allocators to match. + if (self.renderer.search_selected_match) |*m| m.arena.deinit(); + self.renderer.search_selected_match = v; + self.renderer.search_matches_dirty = true; + }, + + .inspector => |v| { + self.flags.has_inspector = v; + }, + + .macos_display_id => |v| { + if (@hasDecl(rendererpkg.Renderer, "setMacOSDisplayID")) { + try self.renderer.setMacOSDisplayID(v); + } + }, + } + } +} + +fn changeConfig(self: *Thread, config: *const DerivedConfig) !void { + self.config = config.*; +} + +/// Trigger a draw. This will not update frame data or anything, it will +/// just trigger a draw/paint. +fn drawFrame(self: *Thread, now: bool) void { + // If we're invisible, we do not draw. + if (!self.flags.visible) return; + + // If the renderer is managing a vsync on its own, we only draw + // when we're forced to via `now`. + if (!now and self.renderer.hasVsync()) return; + + if (must_draw_from_app_thread) { + _ = self.app_mailbox.push( + .{ .redraw_surface = self.surface }, + .{ .instant = {} }, + ); + } else { + self.renderer.drawFrame(false) catch |err| + log.warn("error drawing err={}", .{err}); + } +} + +fn wakeupCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Async.WaitError!void, +) xev.CallbackAction { + _ = r catch |err| { + log.err("error in wakeup err={}", .{err}); + return .rearm; + }; + + const t = self_.?; + + // When we wake up, we check the mailbox. Mailbox producers should + // wake up our thread after publishing. + t.drainMailbox() catch |err| + log.err("error draining mailbox err={}", .{err}); + + // Render immediately + _ = renderCallback(t, undefined, undefined, {}); + + // The below is not used anymore but if we ever want to introduce + // a configuration to introduce a delay to coalesce renders, we can + // use this. + // + // // If the timer is already active then we don't have to do anything. + // if (t.render_c.state() == .active) return .rearm; + // + // // Timer is not active, let's start it + // t.render_h.run( + // &t.loop, + // &t.render_c, + // 10, + // Thread, + // t, + // renderCallback, + // ); + + return .rearm; +} + +fn drawNowCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Async.WaitError!void, +) xev.CallbackAction { + _ = r catch |err| { + log.err("error in draw now err={}", .{err}); + return .rearm; + }; + + // Draw immediately + const t = self_.?; + t.drawFrame(true); + + return .rearm; +} + +fn drawCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.RunError!void, +) xev.CallbackAction { + _ = r catch unreachable; + const t: *Thread = self_ orelse { + // This shouldn't happen so we log it. + log.warn("render callback fired without data set", .{}); + return .disarm; + }; + + // Draw + t.drawFrame(false); + + // Only continue if we're still active + if (t.draw_active) { + t.draw_h.run(&t.loop, &t.draw_c, DRAW_INTERVAL, Thread, t, drawCallback); + } + + return .disarm; +} + +fn renderCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.RunError!void, +) xev.CallbackAction { + _ = r catch unreachable; + const t: *Thread = self_ orelse { + // This shouldn't happen so we log it. + log.warn("render callback fired without data set", .{}); + return .disarm; + }; + + // Update our frame data + t.renderer.updateFrame( + t.state, + t.flags.cursor_blink_visible, + ) catch |err| + log.warn("error rendering err={}", .{err}); + + // Draw + t.drawFrame(false); + + return .disarm; +} + +fn cursorTimerCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.RunError!void, +) xev.CallbackAction { + _ = r catch |err| switch (err) { + // This is sent when our timer is canceled. That's fine. + error.Canceled => return .disarm, + + else => { + log.warn("error in cursor timer callback err={}", .{err}); + unreachable; + }, + }; + + const t: *Thread = self_ orelse { + // This shouldn't happen so we log it. + log.warn("render callback fired without data set", .{}); + return .disarm; + }; + + t.flags.cursor_blink_visible = !t.flags.cursor_blink_visible; + t.wakeup.notify() catch {}; + + t.cursor_h.run( + &t.loop, + &t.cursor_c, + cursorBlinkInterval(), + Thread, + t, + cursorTimerCallback, + ); + return .disarm; +} + +fn cursorCancelCallback( + _: ?*void, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Timer.CancelError!void, +) xev.CallbackAction { + // This makes it easier to work across platforms where different platforms + // support different sets of errors, so we just unify it. + const CancelError = xev.Timer.CancelError || error{ + Canceled, + NotFound, + Unexpected, + }; + + _ = r catch |err| switch (@as(CancelError, @errorCast(err))) { + error.Canceled => {}, // success + error.NotFound => {}, // completed before it could cancel + else => { + log.warn("error in cursor cancel callback err={}", .{err}); + unreachable; + }, + }; + + return .disarm; +} + +// fn prepFrameCallback(h: *libuv.Prepare) void { +// _ = h; +// +// tracy.frameMark(); +// } + +fn stopCallback( + self_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + r: xev.Async.WaitError!void, +) xev.CallbackAction { + _ = r catch unreachable; + self_.?.loop.stop(); + return .disarm; +} + +/// Returns the interval for the blinking cursor in milliseconds. +fn cursorBlinkInterval() u64 { + if (std.valgrind.runningOnValgrind() > 0) { + // If we're running under Valgrind, the cursor blink adds enough + // churn that it makes some stalls annoying unless you're on a + // super powerful computer, so we delay it. + // + // This is a hack, we should change some of our cursor timer + // logic to be more efficient: + // https://github.com/ghostty-org/ghostty/issues/8003 + return CURSOR_BLINK_INTERVAL * 5; + } + + return CURSOR_BLINK_INTERVAL; +} diff --git a/vendor/libghostty-vt/src/renderer/WebGL.zig b/vendor/libghostty-vt/src/renderer/WebGL.zig new file mode 100644 index 00000000..655f7a50 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/WebGL.zig @@ -0,0 +1,2 @@ +//! Renderer implementation for WebGL in the browser. +pub const WebGL = @This(); diff --git a/vendor/libghostty-vt/src/renderer/backend.zig b/vendor/libghostty-vt/src/renderer/backend.zig new file mode 100644 index 00000000..942e1f0f --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/backend.zig @@ -0,0 +1,23 @@ +const std = @import("std"); +const WasmTarget = @import("../os/wasm/target.zig").Target; + +/// Possible implementations, used for build options. +pub const Backend = enum { + opengl, + metal, + webgl, + + pub fn default( + target: std.Target, + wasm_target: WasmTarget, + ) Backend { + if (target.cpu.arch == .wasm32) { + return switch (wasm_target) { + .browser => .webgl, + }; + } + + if (target.os.tag.isDarwin()) return .metal; + return .opengl; + } +}; diff --git a/vendor/libghostty-vt/src/renderer/cell.zig b/vendor/libghostty-vt/src/renderer/cell.zig new file mode 100644 index 00000000..196ebb17 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/cell.zig @@ -0,0 +1,680 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../quirks.zig").inlineAssert; +const font = @import("../font/main.zig"); +const terminal = @import("../terminal/main.zig"); +const renderer = @import("../renderer.zig"); +const shaderpkg = renderer.Renderer.API.shaders; +const ArrayListCollection = @import("../datastruct/array_list_collection.zig").ArrayListCollection; +const symbols = @import("../unicode/symbols_table.zig").table; + +/// The possible cell content keys that exist. +pub const Key = enum { + bg, + text, + underline, + strikethrough, + overline, + + /// Returns the GPU vertex type for this key. + pub fn CellType(self: Key) type { + return switch (self) { + .bg => shaderpkg.CellBg, + + .text, + .underline, + .strikethrough, + .overline, + => shaderpkg.CellText, + }; + } +}; + +/// The contents of all the cells in the terminal. +/// +/// The goal of this data structure is to allow for efficient row-wise +/// clearing of data from the GPU buffers, to allow for row-wise dirty +/// tracking to eliminate the overhead of rebuilding the GPU buffers +/// each frame. +/// +/// Must be initialized by resizing before calling any operations. +pub const Contents = struct { + size: renderer.GridSize = .{ .rows = 0, .columns = 0 }, + + /// Flat array containing cell background colors for the terminal grid. + /// + /// Indexed as `bg_cells[row * size.columns + col]`. + /// + /// Prefer accessing with `Contents.bgCell(row, col).*` instead + /// of directly indexing in order to avoid integer size bugs. + bg_cells: []shaderpkg.CellBg = undefined, + + /// The ArrayListCollection which holds all of the foreground cells. When + /// sized with Contents.resize the individual ArrayLists are given enough + /// room that they can hold a single row with #cols glyphs, underlines, and + /// strikethroughs; however, appendAssumeCapacity MUST NOT be used since + /// it is possible to exceed this with combining glyphs that add a glyph + /// but take up no column since they combine with the previous one, as + /// well as with fonts that perform multi-substitutions for glyphs, which + /// can result in a similar situation where multiple glyphs reside in the + /// same column. + /// + /// Allocations should nevertheless be exceedingly rare since hitting the + /// initial capacity of a list would require a row filled with underlined + /// struck through characters, at least one of which is a multi-glyph + /// composite. + /// + /// Rows are indexed as Contents.fg_rows[y + 1], because the first list in + /// the collection is reserved for the cursor, which must be the first item + /// in the buffer. + /// + /// Must be initialized by calling resize on the Contents struct before + /// calling any operations. + fg_rows: ArrayListCollection(shaderpkg.CellText) = .{ .lists = &.{} }, + + pub fn deinit(self: *Contents, alloc: Allocator) void { + alloc.free(self.bg_cells); + self.fg_rows.deinit(alloc); + } + + /// Resize the cell contents for the given grid size. This will + /// always invalidate the entire cell contents. + pub fn resize( + self: *Contents, + alloc: Allocator, + size: renderer.GridSize, + ) Allocator.Error!void { + self.size = size; + + const cell_count = @as(usize, size.columns) * @as(usize, size.rows); + + const bg_cells = try alloc.alloc(shaderpkg.CellBg, cell_count); + errdefer alloc.free(bg_cells); + @memset(bg_cells, .{ 0, 0, 0, 0 }); + + // The foreground lists can hold 3 types of items: + // - Glyphs + // - Underlines + // - Strikethroughs + // So we give them an initial capacity of size.columns * 3, which will + // avoid any further allocations in the vast majority of cases. Sadly + // we can not assume capacity though, since with combining glyphs that + // form a single grapheme, and multi-substitutions in fonts, the number + // of glyphs in a row is theoretically unlimited. + // + // We have size.rows + 2 lists because indexes 0 and size.rows - 1 are + // used for special lists containing the cursor cell which need to + // be first and last in the buffer, respectively. + var fg_rows: ArrayListCollection(shaderpkg.CellText) = try .init( + alloc, + size.rows + 2, + size.columns * 3, + ); + errdefer fg_rows.deinit(alloc); + + // We don't need 3*cols worth of cells for the cursor lists, so we can + // replace them with smaller lists. This is technically a tiny bit of + // extra work but resize is not a hot function so it's worth it to not + // waste the memory. + fg_rows.lists[0].deinit(alloc); + fg_rows.lists[0] = try .initCapacity(alloc, 1); + fg_rows.lists[size.rows + 1].deinit(alloc); + fg_rows.lists[size.rows + 1] = try .initCapacity(alloc, 1); + + // Perform the swap, no going back from here. + errdefer comptime unreachable; + alloc.free(self.bg_cells); + self.fg_rows.deinit(alloc); + self.bg_cells = bg_cells; + self.fg_rows = fg_rows; + } + + /// Reset the cell contents to an empty state without resizing. + pub fn reset(self: *Contents) void { + @memset(self.bg_cells, .{ 0, 0, 0, 0 }); + self.fg_rows.reset(); + } + + /// Set the cursor value. If the value is null then the cursor is hidden. + pub fn setCursor( + self: *Contents, + v: ?shaderpkg.CellText, + cursor_style: ?renderer.CursorStyle, + ) void { + if (self.size.rows == 0) return; + self.fg_rows.lists[0].clearRetainingCapacity(); + self.fg_rows.lists[self.size.rows + 1].clearRetainingCapacity(); + + const cell = v orelse return; + const style = cursor_style orelse return; + + switch (style) { + // Block cursors should be drawn first + .block => self.fg_rows.lists[0].appendAssumeCapacity(cell), + // Other cursor styles should be drawn last + .block_hollow, .bar, .underline, .lock => self.fg_rows.lists[self.size.rows + 1].appendAssumeCapacity(cell), + } + } + + /// Returns the current cursor glyph if present, checking both cursor lists. + pub fn getCursorGlyph(self: *Contents) ?shaderpkg.CellText { + if (self.size.rows == 0) return null; + if (self.fg_rows.lists[0].items.len > 0) { + return self.fg_rows.lists[0].items[0]; + } + if (self.fg_rows.lists[self.size.rows + 1].items.len > 0) { + return self.fg_rows.lists[self.size.rows + 1].items[0]; + } + return null; + } + + /// Access a background cell. Prefer this function over direct indexing + /// of `bg_cells` in order to avoid integer size bugs causing overflows. + pub inline fn bgCell( + self: *Contents, + row: usize, + col: usize, + ) *shaderpkg.CellBg { + return &self.bg_cells[row * self.size.columns + col]; + } + + /// Add a cell to the appropriate list. Adding the same cell twice will + /// result in duplication in the vertex buffer. The caller should clear + /// the corresponding row with Contents.clear to remove old cells first. + pub fn add( + self: *Contents, + alloc: Allocator, + comptime key: Key, + cell: key.CellType(), + ) Allocator.Error!void { + const y = cell.grid_pos[1]; + + assert(y < self.size.rows); + + switch (key) { + .bg => comptime unreachable, + + .text, + .underline, + .strikethrough, + .overline, + // We have a special list containing the cursor cell at the start + // of our fg row collection, so we need to add 1 to the y to get + // the correct index. + => try self.fg_rows.lists[y + 1].append(alloc, cell), + } + } + + /// Clear all of the cell contents for a given row. + pub fn clear(self: *Contents, y: terminal.size.CellCountInt) void { + assert(y < self.size.rows); + + @memset(self.bg_cells[@as(usize, y) * self.size.columns ..][0..self.size.columns], .{ 0, 0, 0, 0 }); + + // We have a special list containing the cursor cell at the start + // of our fg row collection, so we need to add 1 to the y to get + // the correct index. + self.fg_rows.lists[y + 1].clearRetainingCapacity(); + } +}; + +/// Returns true if a codepoint for a cell is a covering character. A covering +/// character is a character that covers the entire cell. This is used to +/// make window-padding-color=extend work better. See #2099. +pub fn isCovering(cp: u21) bool { + return switch (cp) { + // U+2588 FULL BLOCK + 0x2588 => true, + + else => false, + }; +} + +/// Returns true of the codepoint is a "symbol-like" character, which +/// for now we define as anything in a private use area, and anything +/// in several unicode blocks: +/// - Arrows +/// - Dingbats +/// - Emoticons +/// - Miscellaneous Symbols +/// - Enclosed Alphanumerics +/// - Enclosed Alphanumeric Supplement +/// - Miscellaneous Symbols and Pictographs +/// - Transport and Map Symbols +/// +/// In the future it may be prudent to expand this to encompass more +/// symbol-like characters, and/or exclude some PUA sections. +pub fn isSymbol(cp: u21) bool { + return symbols.get(cp); +} + +/// Returns the appropriate `constraint_width` for +/// the provided cell when rendering its glyph(s). +pub fn constraintWidth( + raw_slice: []const terminal.page.Cell, + x: usize, + cols: usize, +) u2 { + const cell = raw_slice[x]; + const cp = cell.codepoint(); + + const grid_width = cell.gridWidth(); + + // If the grid width of the cell is 2, the constraint + // width will always be 2, so we can just return early. + if (grid_width > 1) return grid_width; + + // We allow "symbol-like" glyphs to extend to 2 cells wide if there's + // space, and if the previous glyph wasn't also a symbol. So if this + // codepoint isn't a symbol then we can return the grid width. + if (!isSymbol(cp)) return grid_width; + + // If we are at the end of the screen it must be constrained to one cell. + if (x == cols - 1) return 1; + + // If we have a previous cell and it was a symbol then we need + // to also constrain. This is so that multiple PUA glyphs align. + // This does not apply if the previous symbol is a graphics + // element such as a block element or Powerline glyph. + if (x > 0) { + const prev_cp = raw_slice[x - 1].codepoint(); + if (isSymbol(prev_cp) and !isGraphicsElement(prev_cp)) { + return 1; + } + } + + // If the next cell is whitespace, then we + // allow the glyph to be up to two cells wide. + const next_cp = raw_slice[x + 1].codepoint(); + if (next_cp == 0 or isSpace(next_cp)) return 2; + + // Otherwise, this has to be 1 cell wide. + return 1; +} + +/// Whether min contrast should be disabled for a given glyph. True +/// for graphics elements such as blocks and Powerline glyphs. +pub fn noMinContrast(cp: u21) bool { + return isGraphicsElement(cp); +} + +// Some general spaces, others intentionally kept +// to force the font to render as a fixed width. +fn isSpace(char: u21) bool { + return switch (char) { + 0x0020, // SPACE + 0x2002, // EN SPACE + => true, + else => false, + }; +} + +/// Returns true if the codepoint is used for terminal graphics, such +/// as box drawing characters, block elements, and Powerline glyphs. +fn isGraphicsElement(char: u21) bool { + return isBoxDrawing(char) or isBlockElement(char) or isLegacyComputing(char) or isPowerline(char); +} + +// Returns true if the codepoint is a box drawing character. +fn isBoxDrawing(char: u21) bool { + return switch (char) { + 0x2500...0x257F => true, + else => false, + }; +} + +// Returns true if the codepoint is a block element. +fn isBlockElement(char: u21) bool { + return switch (char) { + 0x2580...0x259F => true, + else => false, + }; +} + +// Returns true if the codepoint is in a Symbols for Legacy +// Computing block, including supplements. +fn isLegacyComputing(char: u21) bool { + return switch (char) { + 0x1FB00...0x1FBFF => true, + 0x1CC00...0x1CEBF => true, // Supplement introduced in Unicode 16.0 + else => false, + }; +} + +// Returns true if the codepoint is a part of the Powerline range. +fn isPowerline(char: u21) bool { + return switch (char) { + 0xE0B0...0xE0D7 => true, + else => false, + }; +} + +test Contents { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // We should start off empty after resizing. + for (0..rows) |y| { + try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); + for (0..cols) |x| { + try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); + } + } + // And the cursor row should have a capacity of 1 and also be empty. + try testing.expect(c.fg_rows.lists[0].capacity == 1); + try testing.expect(c.fg_rows.lists[0].items.len == 0); + + // Add some contents. + const bg_cell: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell: shaderpkg.CellText = .{ + .atlas = .grayscale, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell; + try c.add(alloc, .text, fg_cell); + try testing.expectEqual(bg_cell, c.bgCell(1, 4).*); + // The fg row index is offset by 1 because of the cursor list. + try testing.expectEqual(fg_cell, c.fg_rows.lists[2].items[0]); + + // And we should be able to clear it. + c.clear(1); + for (0..rows) |y| { + try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); + for (0..cols) |x| { + try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); + } + } + + // Add a block cursor. + const cursor_cell: shaderpkg.CellText = .{ + .atlas = .grayscale, + .bools = .{ .is_cursor_glyph = true }, + .grid_pos = .{ 2, 3 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.setCursor(cursor_cell, .block); + try testing.expectEqual(cursor_cell, c.fg_rows.lists[0].items[0]); + try testing.expectEqual(cursor_cell, c.getCursorGlyph().?); + + // And remove it. + c.setCursor(null, null); + try testing.expectEqual(0, c.fg_rows.lists[0].items.len); + try testing.expect(c.getCursorGlyph() == null); + + // Add a hollow cursor. + c.setCursor(cursor_cell, .block_hollow); + try testing.expectEqual(cursor_cell, c.fg_rows.lists[rows + 1].items[0]); + try testing.expectEqual(cursor_cell, c.getCursorGlyph().?); +} + +test "Contents clear retains other content" { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // Set some contents + // bg and fg cells in row 1 + const bg_cell_1: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_1: shaderpkg.CellText = .{ + .atlas = .grayscale, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell_1; + try c.add(alloc, .text, fg_cell_1); + // bg and fg cells in row 2 + const bg_cell_2: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_2: shaderpkg.CellText = .{ + .atlas = .grayscale, + .grid_pos = .{ 4, 2 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(2, 4).* = bg_cell_2; + try c.add(alloc, .text, fg_cell_2); + + // Clear row 1, this should leave row 2 untouched + c.clear(1); + + // Row 2 should still contain its cells. + try testing.expectEqual(bg_cell_2, c.bgCell(2, 4).*); + // Fg row index is +1 because of cursor list at start + try testing.expectEqual(fg_cell_2, c.fg_rows.lists[3].items[0]); +} + +test "Contents clear last added content" { + const testing = std.testing; + const alloc = testing.allocator; + + const rows = 10; + const cols = 10; + + var c: Contents = .{}; + try c.resize(alloc, .{ .rows = rows, .columns = cols }); + defer c.deinit(alloc); + + // Set some contents + // bg and fg cells in row 1 + const bg_cell_1: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_1: shaderpkg.CellText = .{ + .atlas = .grayscale, + .grid_pos = .{ 4, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(1, 4).* = bg_cell_1; + try c.add(alloc, .text, fg_cell_1); + // bg and fg cells in row 2 + const bg_cell_2: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; + const fg_cell_2: shaderpkg.CellText = .{ + .atlas = .grayscale, + .grid_pos = .{ 4, 2 }, + .color = .{ 0, 0, 0, 1 }, + }; + c.bgCell(2, 4).* = bg_cell_2; + try c.add(alloc, .text, fg_cell_2); + + // Clear row 2, this should leave row 1 untouched + c.clear(2); + + // Row 1 should still contain its cells. + try testing.expectEqual(bg_cell_1, c.bgCell(1, 4).*); + // Fg row index is +1 because of cursor list at start + try testing.expectEqual(fg_cell_1, c.fg_rows.lists[2].items[0]); +} + +test "Contents with zero-sized screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Contents = .{}; + defer c.deinit(alloc); + + c.setCursor(null, null); + try testing.expect(c.getCursorGlyph() == null); +} + +test "Cell constraint widths" { + const testing = std.testing; + const alloc = testing.allocator; + + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 4, + .rows = 1, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + + // for each case, the numbers in the comment denote expected + // constraint widths for the symbol-containing cells + + // symbol->nothing: 2 + { + t.fullReset(); + s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + } + + // symbol->character: 1 + { + t.fullReset(); + s.nextSlice("z"); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + } + + // symbol->space: 2 + { + t.fullReset(); + s.nextSlice(" z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + } + // symbol->no-break space: 1 + { + t.fullReset(); + s.nextSlice("\u{00a0}z"); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + } + + // symbol->end of row: 1 + { + t.fullReset(); + s.nextSlice(" "); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 3, + state.cols, + )); + } + + // character->symbol: 2 + { + t.fullReset(); + s.nextSlice("z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); + } + + // symbol->symbol: 1,1 + { + t.fullReset(); + s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); + } + + // symbol->space->symbol: 2,2 + { + t.fullReset(); + s.nextSlice(" "); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 2, + state.cols, + )); + } + + // symbol->powerline: 1 (dedicated test because powerline is special-cased in cellpkg) + { + t.fullReset(); + s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(1, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + } + + // powerline->symbol: 2 (dedicated test because powerline is special-cased in cellpkg) + { + t.fullReset(); + s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 1, + state.cols, + )); + } + + // powerline->nothing: 2 (dedicated test because powerline is special-cased in cellpkg) + { + t.fullReset(); + s.nextSlice(""); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + } + + // powerline->space: 2 (dedicated test because powerline is special-cased in cellpkg) + { + t.fullReset(); + s.nextSlice(" z"); + try state.update(alloc, &t); + try testing.expectEqual(2, constraintWidth( + state.row_data.get(0).cells.items(.raw), + 0, + state.cols, + )); + } +} diff --git a/vendor/libghostty-vt/src/renderer/cursor.zig b/vendor/libghostty-vt/src/renderer/cursor.zig new file mode 100644 index 00000000..33992bc5 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/cursor.zig @@ -0,0 +1,154 @@ +const std = @import("std"); +const terminal = @import("../terminal/main.zig"); + +/// Available cursor styles for drawing that renderers must support. +/// This is a superset of terminal cursor styles since the renderer supports +/// some additional cursor states such as the hollow block. +pub const Style = enum { + // Typical cursor input styles + block, + block_hollow, + bar, + underline, + + // Special cursor styles + lock, + + /// Create a cursor style from the terminal style request. + pub fn fromTerminal(term: terminal.CursorStyle) Style { + return switch (term) { + .bar => .bar, + .block => .block, + .block_hollow => .block_hollow, + .underline => .underline, + }; + } +}; + +pub const StyleOptions = struct { + preedit: bool = false, + focused: bool = false, + blink_visible: bool = false, +}; + +/// Returns the cursor style to use for the current render state or null +/// if a cursor should not be rendered at all. +pub fn style( + state: *const terminal.RenderState, + opts: StyleOptions, +) ?Style { + // Note the order of conditionals below is important. It represents + // a priority system of how we determine what state overrides cursor + // visibility and style. + + // The cursor must be visible in the viewport to be rendered. + if (state.cursor.viewport == null) return null; + + // If we are in preedit, then we always show the block cursor. We do + // this even if the cursor is explicitly not visible because it shows + // an important editing state to the user. + if (opts.preedit) return .block; + + // If we're at a password input its always a lock. + if (state.cursor.password_input) return .lock; + + // If the cursor is explicitly not visible by terminal mode, we don't render. + if (!state.cursor.visible) return null; + + // If we're not focused, our cursor is always visible so that + // we can show the hollow box. + if (!opts.focused) return .block_hollow; + + // If the cursor is blinking and our blink state is not visible, + // then we don't show the cursor. + if (state.cursor.blinking and !opts.blink_visible) return null; + + // Otherwise, we use whatever style the terminal wants. + return .fromTerminal(state.cursor.visual_style); +} + +test "cursor: default uses configured style" { + const testing = std.testing; + const alloc = testing.allocator; + var term: terminal.Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 }); + defer term.deinit(alloc); + + term.screens.active.cursor.cursor_style = .bar; + term.modes.set(.cursor_blinking, true); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); + + try testing.expect(style(&state, .{ .preedit = false, .focused = true, .blink_visible = true }) == .bar); + try testing.expect(style(&state, .{ .preedit = false, .focused = false, .blink_visible = true }) == .block_hollow); + try testing.expect(style(&state, .{ .preedit = false, .focused = false, .blink_visible = false }) == .block_hollow); + try testing.expect(style(&state, .{ .preedit = false, .focused = true, .blink_visible = false }) == null); +} + +test "cursor: blinking disabled" { + const testing = std.testing; + const alloc = testing.allocator; + var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); + defer term.deinit(alloc); + + term.screens.active.cursor.cursor_style = .bar; + term.modes.set(.cursor_blinking, false); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); + + try testing.expect(style(&state, .{ .focused = true, .blink_visible = true }) == .bar); + try testing.expect(style(&state, .{ .focused = true, .blink_visible = false }) == .bar); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = true }) == .block_hollow); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = false }) == .block_hollow); +} + +test "cursor: explicitly not visible" { + const testing = std.testing; + const alloc = testing.allocator; + var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); + defer term.deinit(alloc); + + term.screens.active.cursor.cursor_style = .bar; + term.modes.set(.cursor_visible, false); + term.modes.set(.cursor_blinking, false); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); + + try testing.expect(style(&state, .{ .focused = true, .blink_visible = true }) == null); + try testing.expect(style(&state, .{ .focused = true, .blink_visible = false }) == null); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = true }) == null); + try testing.expect(style(&state, .{ .focused = false, .blink_visible = false }) == null); +} + +test "cursor: always block with preedit" { + const testing = std.testing; + const alloc = testing.allocator; + var term = try terminal.Terminal.init(alloc, .{ .cols = 10, .rows = 10 }); + defer term.deinit(alloc); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &term); + + // In any bool state + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = false }) == .block); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = false }) == .block); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = true }) == .block); + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = true }) == .block); + + // If we're scrolled though, then we don't show the cursor. + for (0..100) |_| try term.index(); + term.scrollViewport(.{ .top = {} }); + try state.update(alloc, &term); + + // In any bool state + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = false }) == null); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = false }) == null); + try testing.expect(style(&state, .{ .preedit = true, .focused = true, .blink_visible = true }) == null); + try testing.expect(style(&state, .{ .preedit = true, .focused = false, .blink_visible = true }) == null); +} diff --git a/vendor/libghostty-vt/src/renderer/generic.zig b/vendor/libghostty-vt/src/renderer/generic.zig new file mode 100644 index 00000000..0f4a294b --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/generic.zig @@ -0,0 +1,3374 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const xev = @import("xev"); +const wuffs = @import("wuffs"); +const apprt = @import("../apprt.zig"); +const configpkg = @import("../config.zig"); +const font = @import("../font/main.zig"); +const inputpkg = @import("../input.zig"); +const os = @import("../os/main.zig"); +const terminal = @import("../terminal/main.zig"); +const renderer = @import("../renderer.zig"); +const math = @import("../math.zig"); +const Surface = @import("../Surface.zig"); +const link = @import("link.zig"); +const cellpkg = @import("cell.zig"); +const noMinContrast = cellpkg.noMinContrast; +const constraintWidth = cellpkg.constraintWidth; +const isCovering = cellpkg.isCovering; +const rowNeverExtendBg = @import("row.zig").neverExtendBg; +const Overlay = @import("Overlay.zig"); +const imagepkg = @import("image.zig"); +const ImageState = imagepkg.State; +const shadertoy = @import("shadertoy.zig"); +const assert = @import("../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const Terminal = terminal.Terminal; +const Health = renderer.Health; + +const getConstraint = @import("../font/nerd_font_attributes.zig").getConstraint; + +const FileType = @import("../file_type.zig").FileType; + +const macos = switch (builtin.os.tag) { + .macos => @import("macos"), + else => void, +}; + +const DisplayLink = switch (builtin.os.tag) { + .macos => *macos.video.DisplayLink, + else => void, +}; + +const log = std.log.scoped(.generic_renderer); + +/// Create a renderer type with the provided graphics API wrapper. +/// +/// The graphics API wrapper must provide the interface outlined below. +/// Specific details for the interfaces are documented on the existing +/// implementations (`Metal` and `OpenGL`). +/// +/// Hierarchy of graphics abstractions: +/// +/// [ GraphicsAPI ] - Responsible for configuring the runtime surface +/// | | and providing render `Target`s that draw to it, +/// | | as well as `Frame`s and `Pipeline`s. +/// | V +/// | [ Target ] - Represents an abstract target for rendering, which +/// | could be a surface directly but is also used as an +/// | abstraction for off-screen frame buffers. +/// V +/// [ Frame ] - Represents the context for drawing a given frame, +/// | provides `RenderPass`es for issuing draw commands +/// | to, and reports the frame health when complete. +/// V +/// [ RenderPass ] - Represents a render pass in a frame, consisting of +/// : one or more `Step`s applied to the same target(s), +/// [ Step ] - - - - each describing the input buffers and textures and +/// : the vertex/fragment functions and geometry to use. +/// :_ _ _ _ _ _ _ _ _ _/ +/// v +/// [ Pipeline ] - Describes a vertex and fragment function to be used +/// for a `Step`; the `GraphicsAPI` is responsible for +/// these and they should be constructed and cached +/// ahead of time. +/// +/// [ Buffer ] - An abstraction over a GPU buffer. +/// +/// [ Texture ] - An abstraction over a GPU texture. +/// +pub fn Renderer(comptime GraphicsAPI: type) type { + return struct { + const Self = @This(); + + pub const API = GraphicsAPI; + + const Target = GraphicsAPI.Target; + const Buffer = GraphicsAPI.Buffer; + const Sampler = GraphicsAPI.Sampler; + const Texture = GraphicsAPI.Texture; + const RenderPass = GraphicsAPI.RenderPass; + + const shaderpkg = GraphicsAPI.shaders; + const Shaders = shaderpkg.Shaders; + + /// Allocator that can be used + alloc: std.mem.Allocator, + + /// This mutex must be held whenever any state used in `drawFrame` is + /// being modified, and also when it's being accessed in `drawFrame`. + draw_mutex: std.Thread.Mutex = .{}, + + /// The configuration we need derived from the main config. + config: DerivedConfig, + + /// The mailbox for communicating with the window. + surface_mailbox: apprt.surface.Mailbox, + + /// Current font metrics defining our grid. + grid_metrics: font.Metrics, + + /// The size of everything. + size: renderer.Size, + + /// True if the window is focused + focused: bool, + + /// Flag to indicate that our focus state changed for custom + /// shaders to update their state. + custom_shader_focused_changed: bool = false, + + /// The most recent scrollbar state. We use this as a cache to + /// determine if we need to notify the apprt that there was a + /// scrollbar change. + scrollbar: terminal.Scrollbar, + scrollbar_dirty: bool, + + /// Tracks the last bottom-right pin of the screen to detect new output. + /// When the final line changes (node or y differs), new content was added. + /// Used for scroll-to-bottom on output feature. + last_bottom_node: ?usize, + last_bottom_y: terminal.size.CellCountInt, + + /// The most recent viewport matches so that we can render search + /// matches in the visible frame. This is provided asynchronously + /// from the search thread so we have the dirty flag to also note + /// if we need to rebuild our cells to include search highlights. + /// + /// Note that the selections MAY BE INVALID (point to PageList nodes + /// that do not exist anymore). These must be validated prior to use. + search_matches: ?renderer.Message.SearchMatches, + search_selected_match: ?renderer.Message.SearchMatch, + search_matches_dirty: bool, + + /// The current set of cells to render. This is rebuilt on every frame + /// but we keep this around so that we don't reallocate. Each set of + /// cells goes into a separate shader. + cells: cellpkg.Contents, + + /// Set to true after rebuildCells is called. This can be used + /// to determine if any possible changes have been made to the + /// cells for the draw call. + cells_rebuilt: bool = false, + + /// The current GPU uniform values. + uniforms: shaderpkg.Uniforms, + + /// Custom shader uniform values. + custom_shader_uniforms: shadertoy.Uniforms, + + /// Timestamp we rendered out first frame. + /// + /// This is used when updating custom shader uniforms. + first_frame_time: ?std.time.Instant = null, + + /// Timestamp when we rendered out more recent frame. + /// + /// This is used when updating custom shader uniforms. + last_frame_time: ?std.time.Instant = null, + + /// The font structures. + font_grid: *font.SharedGrid, + font_shaper: font.Shaper, + font_shaper_cache: font.ShaperCache, + + /// The images that we may render. + images: ImageState = .empty, + + /// Background image, if we have one. + bg_image: ?imagepkg.Image = null, + /// Set whenever the background image changes, signalling + /// that the new background image needs to be uploaded to + /// the GPU. + /// + /// This is initialized as true so that we load the image + /// on renderer initialization, not just on config change. + bg_image_changed: bool = true, + /// Background image vertex buffer. + bg_image_buffer: shaderpkg.BgImage, + /// This value is used to force-update the swap chain copy + /// of the background image buffer whenever we change it. + bg_image_buffer_modified: usize = 0, + + /// Graphics API state. + api: GraphicsAPI, + + /// The CVDisplayLink used to drive the rendering loop in + /// sync with the display. This is void on platforms that + /// don't support a display link. + display_link: ?DisplayLink = null, + + /// Health of the most recently completed frame. + health: std.atomic.Value(Health) = .{ .raw = .healthy }, + + /// Our swap chain (multiple buffering) + swap_chain: SwapChain, + + /// This value is used to force-update swap chain targets in the + /// event of a config change that requires it (such as blending mode). + target_config_modified: usize = 0, + + /// If something happened that requires us to reinitialize our shaders, + /// this is set to true so that we can do that whenever possible. + reinitialize_shaders: bool = false, + + /// Whether or not we have custom shaders. + has_custom_shaders: bool = false, + + /// Our shader pipelines. + shaders: Shaders, + + /// The render state we update per loop. + terminal_state: terminal.RenderState = .empty, + + /// The number of frames since the last terminal state reset. + /// We reset the terminal state after ~100,000 frames (about 10 to + /// 15 minutes at 120Hz) to prevent wasted memory buildup from + /// a large screen. + terminal_state_frame_count: usize = 0, + + /// Our overlay state, if any. + overlay: ?Overlay = null, + + const HighlightTag = enum(u8) { + search_match, + search_match_selected, + }; + /// Swap chain which maintains multiple copies of the state needed to + /// render a frame, so that we can start building the next frame while + /// the previous frame is still being processed on the GPU. + const SwapChain = struct { + // The count of buffers we use for double/triple buffering. + // If this is one then we don't do any double+ buffering at all. + // This is comptime because there isn't a good reason to change + // this at runtime and there is a lot of complexity to support it. + const buf_count = GraphicsAPI.swap_chain_count; + + /// `buf_count` structs that can hold the + /// data needed by the GPU to draw a frame. + frames: [buf_count]FrameState, + /// Index of the most recently used frame state struct. + frame_index: std.math.IntFittingRange(0, buf_count) = 0, + /// Semaphore that we wait on to make sure we have an available + /// frame state struct so we can start working on a new frame. + frame_sema: std.Thread.Semaphore = .{ .permits = buf_count }, + + /// Set to true when deinited, if you try to deinit a defunct + /// swap chain it will just be ignored, to prevent double-free. + /// + /// This is required because of `displayUnrealized`, since it + /// `deinits` the swapchain, which leads to a double-free if + /// the renderer is deinited after that. + defunct: bool = false, + + pub fn init(api: GraphicsAPI, custom_shaders: bool) !SwapChain { + var result: SwapChain = .{ .frames = undefined }; + + // Initialize all of our frame state. + for (&result.frames) |*frame| { + frame.* = try FrameState.init(api, custom_shaders); + } + + return result; + } + + pub fn deinit(self: *SwapChain) void { + if (self.defunct) return; + self.defunct = true; + + // Wait for all of our inflight draws to complete + // so that we can cleanly deinit our GPU state. + for (0..buf_count) |_| self.frame_sema.wait(); + for (&self.frames) |*frame| frame.deinit(); + } + + /// Get the next frame state to draw to. This will wait on the + /// semaphore to ensure that the frame is available. This must + /// always be paired with a call to releaseFrame. + pub fn nextFrame(self: *SwapChain) error{Defunct}!*FrameState { + if (self.defunct) return error.Defunct; + + self.frame_sema.wait(); + errdefer self.frame_sema.post(); + self.frame_index = (self.frame_index + 1) % buf_count; + return &self.frames[self.frame_index]; + } + + /// This should be called when the frame has completed drawing. + pub fn releaseFrame(self: *SwapChain) void { + self.frame_sema.post(); + } + }; + + /// State we need duplicated for every frame. Any state that could be + /// in a data race between the GPU and CPU while a frame is being drawn + /// should be in this struct. + /// + /// While a draw is in-process, we "lock" the state (via a semaphore) + /// and prevent the CPU from updating the state until our graphics API + /// reports that the frame is complete. + /// + /// This is used to implement double/triple buffering. + const FrameState = struct { + uniforms: UniformBuffer, + cells: CellTextBuffer, + cells_bg: CellBgBuffer, + + grayscale: Texture, + grayscale_modified: usize = 0, + color: Texture, + color_modified: usize = 0, + + target: Target, + /// See property of same name on Renderer for explanation. + target_config_modified: usize = 0, + + /// Buffer with the vertex data for our background image. + /// + /// TODO: Make this an optional and only create it + /// if we actually have a background image. + bg_image_buffer: BgImageBuffer, + /// See property of same name on Renderer for explanation. + bg_image_buffer_modified: usize = 0, + + /// Custom shader state, this is null if we have no custom shaders. + custom_shader_state: ?CustomShaderState = null, + + const UniformBuffer = Buffer(shaderpkg.Uniforms); + const CellBgBuffer = Buffer(shaderpkg.CellBg); + const CellTextBuffer = Buffer(shaderpkg.CellText); + const BgImageBuffer = Buffer(shaderpkg.BgImage); + + pub fn init(api: GraphicsAPI, custom_shaders: bool) !FrameState { + // Uniform buffer contains exactly 1 uniform struct. The + // uniform data will be undefined so this must be set before + // a frame is drawn. + var uniforms = try UniformBuffer.init(api.uniformBufferOptions(), 1); + errdefer uniforms.deinit(); + + // Create GPU buffers for our cells. + // + // We start them off with a size of 1, which will of course be + // too small, but they will be resized as needed. This is a bit + // wasteful but since it's a one-time thing it's not really a + // huge concern. + var cells = try CellTextBuffer.init(api.fgBufferOptions(), 1); + errdefer cells.deinit(); + var cells_bg = try CellBgBuffer.init(api.bgBufferOptions(), 1); + errdefer cells_bg.deinit(); + + // Create a GPU buffer for our background image info. + var bg_image_buffer = try BgImageBuffer.init( + api.bgImageBufferOptions(), + 1, + ); + errdefer bg_image_buffer.deinit(); + + // Initialize our textures for our font atlas. + // + // As with the buffers above, we start these off as small + // as possible since they'll inevitably be resized anyway. + const grayscale = try api.initAtlasTexture(&.{ + .data = undefined, + .size = 1, + .format = .grayscale, + }); + errdefer grayscale.deinit(); + const color = try api.initAtlasTexture(&.{ + .data = undefined, + .size = 1, + .format = .bgra, + }); + errdefer color.deinit(); + + var custom_shader_state = + if (custom_shaders) + try CustomShaderState.init(api) + else + null; + errdefer if (custom_shader_state) |*state| state.deinit(); + + // Initialize the target. Just as with the other resources, + // start it off as small as we can since it'll be resized. + const target = try api.initTarget(1, 1); + + return .{ + .uniforms = uniforms, + .cells = cells, + .cells_bg = cells_bg, + .bg_image_buffer = bg_image_buffer, + .grayscale = grayscale, + .color = color, + .target = target, + .custom_shader_state = custom_shader_state, + }; + } + + pub fn deinit(self: *FrameState) void { + self.target.deinit(); + self.uniforms.deinit(); + self.cells.deinit(); + self.cells_bg.deinit(); + self.grayscale.deinit(); + self.color.deinit(); + self.bg_image_buffer.deinit(); + if (self.custom_shader_state) |*state| state.deinit(); + } + + pub fn resize( + self: *FrameState, + api: GraphicsAPI, + width: usize, + height: usize, + ) !void { + if (self.custom_shader_state) |*state| { + try state.resize(api, width, height); + } + const target = try api.initTarget(width, height); + self.target.deinit(); + self.target = target; + } + }; + + /// State relevant to our custom shaders if we have any. + const CustomShaderState = struct { + /// When we have a custom shader state, we maintain a front + /// and back texture which we use as a swap chain to render + /// between when multiple custom shaders are defined. + front_texture: Texture, + back_texture: Texture, + + /// Shadertoy uses a sampler for accessing the various channel + /// textures. In Metal, we need to explicitly create these since + /// the glslang-to-msl compiler doesn't do it for us (as we + /// normally would in hand-written MSL). To keep it clean and + /// consistent, we just force all rendering APIs to provide an + /// explicit sampler. + /// + /// Samplers are immutable and describe sampling properties so + /// we can share the sampler across front/back textures (although + /// we only need it for the source texture at a time, we don't + /// need to "swap" it). + sampler: Sampler, + + uniforms: UniformBuffer, + + const UniformBuffer = Buffer(shadertoy.Uniforms); + + /// Swap the front and back textures. + pub fn swap(self: *CustomShaderState) void { + std.mem.swap(Texture, &self.front_texture, &self.back_texture); + } + + pub fn init(api: GraphicsAPI) !CustomShaderState { + // Create a GPU buffer to hold our uniforms. + var uniforms = try UniformBuffer.init(api.uniformBufferOptions(), 1); + errdefer uniforms.deinit(); + + // Initialize the front and back textures at 1x1 px, this + // is slightly wasteful but it's only done once so whatever. + const front_texture = try Texture.init( + api.textureOptions(), + 1, + 1, + null, + ); + errdefer front_texture.deinit(); + const back_texture = try Texture.init( + api.textureOptions(), + 1, + 1, + null, + ); + errdefer back_texture.deinit(); + + const sampler = try Sampler.init(api.samplerOptions()); + errdefer sampler.deinit(); + + return .{ + .front_texture = front_texture, + .back_texture = back_texture, + .sampler = sampler, + .uniforms = uniforms, + }; + } + + pub fn deinit(self: *CustomShaderState) void { + self.front_texture.deinit(); + self.back_texture.deinit(); + self.sampler.deinit(); + self.uniforms.deinit(); + } + + pub fn resize( + self: *CustomShaderState, + api: GraphicsAPI, + width: usize, + height: usize, + ) !void { + const front_texture = try Texture.init( + api.textureOptions(), + @intCast(width), + @intCast(height), + null, + ); + errdefer front_texture.deinit(); + const back_texture = try Texture.init( + api.textureOptions(), + @intCast(width), + @intCast(height), + null, + ); + errdefer back_texture.deinit(); + + self.front_texture.deinit(); + self.back_texture.deinit(); + + self.front_texture = front_texture; + self.back_texture = back_texture; + } + }; + + /// The configuration for this renderer that is derived from the main + /// configuration. This must be exported so that we don't need to + /// pass around Config pointers which makes memory management a pain. + pub const DerivedConfig = struct { + arena: ArenaAllocator, + + font_thicken: bool, + font_thicken_strength: u8, + font_features: std.ArrayListUnmanaged([:0]const u8), + font_styles: font.CodepointResolver.StyleStatus, + font_shaping_break: configpkg.FontShapingBreak, + cursor_color: ?configpkg.Config.TerminalColor, + cursor_opacity: f64, + cursor_text: ?configpkg.Config.TerminalColor, + background: terminal.color.RGB, + background_opacity: f64, + background_opacity_cells: bool, + foreground: terminal.color.RGB, + selection_background: ?configpkg.Config.TerminalColor, + selection_foreground: ?configpkg.Config.TerminalColor, + search_background: configpkg.Config.TerminalColor, + search_foreground: configpkg.Config.TerminalColor, + search_selected_background: configpkg.Config.TerminalColor, + search_selected_foreground: configpkg.Config.TerminalColor, + bold_color: ?terminal.Style.BoldColor, + faint_opacity: u8, + min_contrast: f32, + padding_color: configpkg.WindowPaddingColor, + custom_shaders: configpkg.RepeatablePath, + bg_image: ?configpkg.Path, + bg_image_opacity: f32, + bg_image_position: configpkg.BackgroundImagePosition, + bg_image_fit: configpkg.BackgroundImageFit, + bg_image_repeat: bool, + links: link.Set, + vsync: bool, + colorspace: configpkg.Config.WindowColorspace, + blending: configpkg.Config.AlphaBlending, + background_blur: configpkg.Config.BackgroundBlur, + scroll_to_bottom_on_output: bool, + + pub fn init( + alloc_gpa: Allocator, + config: *const configpkg.Config, + ) !DerivedConfig { + var arena = ArenaAllocator.init(alloc_gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Copy our shaders + const custom_shaders = try config.@"custom-shader".clone(alloc); + + // Copy our background image + const bg_image = + if (config.@"background-image") |bg| + try bg.clone(alloc) + else + null; + + // Copy our font features + const font_features = try config.@"font-feature".clone(alloc); + + // Get our font styles + var font_styles = font.CodepointResolver.StyleStatus.initFill(true); + font_styles.set(.bold, config.@"font-style-bold" != .false); + font_styles.set(.italic, config.@"font-style-italic" != .false); + font_styles.set(.bold_italic, config.@"font-style-bold-italic" != .false); + + // Our link configs + const links = try link.Set.fromConfig( + alloc, + config.link.links.items, + ); + + return .{ + .background_opacity = @max(0, @min(1, config.@"background-opacity")), + .background_opacity_cells = config.@"background-opacity-cells", + .font_thicken = config.@"font-thicken", + .font_thicken_strength = config.@"font-thicken-strength", + .font_features = font_features.list, + .font_styles = font_styles, + .font_shaping_break = config.@"font-shaping-break", + + .cursor_color = config.@"cursor-color", + .cursor_text = config.@"cursor-text", + .cursor_opacity = @max(0, @min(1, config.@"cursor-opacity")), + + .background = config.background.toTerminalRGB(), + .foreground = config.foreground.toTerminalRGB(), + .bold_color = if (config.@"bold-color") |b| b.toTerminal() else null, + .faint_opacity = @intFromFloat(@ceil(config.@"faint-opacity" * 255)), + + .min_contrast = @floatCast(config.@"minimum-contrast"), + .padding_color = config.@"window-padding-color", + + .selection_background = config.@"selection-background", + .selection_foreground = config.@"selection-foreground", + .search_background = config.@"search-background", + .search_foreground = config.@"search-foreground", + .search_selected_background = config.@"search-selected-background", + .search_selected_foreground = config.@"search-selected-foreground", + + .custom_shaders = custom_shaders, + .bg_image = bg_image, + .bg_image_opacity = config.@"background-image-opacity", + .bg_image_position = config.@"background-image-position", + .bg_image_fit = config.@"background-image-fit", + .bg_image_repeat = config.@"background-image-repeat", + .links = links, + .vsync = config.@"window-vsync", + .colorspace = config.@"window-colorspace", + .blending = config.@"alpha-blending", + .background_blur = config.@"background-blur", + .scroll_to_bottom_on_output = config.@"scroll-to-bottom".output, + .arena = arena, + }; + } + + pub fn deinit(self: *DerivedConfig) void { + const alloc = self.arena.allocator(); + self.links.deinit(alloc); + self.arena.deinit(); + } + }; + + pub fn init(alloc: Allocator, options: renderer.Options) !Self { + // Initialize our graphics API wrapper, this will prepare the + // surface provided by the apprt and set up any API-specific + // GPU resources. + var api = try GraphicsAPI.init(alloc, options); + errdefer api.deinit(); + + const has_custom_shaders = options.config.custom_shaders.value.items.len > 0; + + // Prepare our swap chain + var swap_chain = try SwapChain.init( + api, + has_custom_shaders, + ); + errdefer swap_chain.deinit(); + + // Create the font shaper. + var font_shaper = try font.Shaper.init(alloc, .{ + .features = options.config.font_features.items, + }); + errdefer font_shaper.deinit(); + + // Initialize all the data that requires a critical font section. + const font_critical: struct { + metrics: font.Metrics, + } = font_critical: { + const grid: *font.SharedGrid = options.font_grid; + grid.lock.lockShared(); + defer grid.lock.unlockShared(); + break :font_critical .{ + .metrics = grid.metrics, + }; + }; + + const display_link: ?DisplayLink = switch (builtin.os.tag) { + .macos => if (options.config.vsync) + try macos.video.DisplayLink.createWithActiveCGDisplays() + else + null, + else => null, + }; + errdefer if (display_link) |v| v.release(); + + var result: Self = .{ + .alloc = alloc, + .config = options.config, + .surface_mailbox = options.surface_mailbox, + .grid_metrics = font_critical.metrics, + .size = options.size, + .focused = true, + .scrollbar = .zero, + .scrollbar_dirty = false, + .last_bottom_node = null, + .last_bottom_y = 0, + .search_matches = null, + .search_selected_match = null, + .search_matches_dirty = false, + + // Render state + .cells = .{}, + .uniforms = .{ + .projection_matrix = undefined, + .cell_size = undefined, + .grid_size = undefined, + .grid_padding = undefined, + .screen_size = undefined, + .padding_extend = .{}, + .min_contrast = options.config.min_contrast, + .cursor_pos = .{ std.math.maxInt(u16), std.math.maxInt(u16) }, + .cursor_color = undefined, + .bg_color = .{ + options.config.background.r, + options.config.background.g, + options.config.background.b, + // Note that if we're on macOS with glass effects + // we'll disable background opacity but we handle + // that in updateFrame. + @intFromFloat(@round(options.config.background_opacity * 255.0)), + }, + .bools = .{ + .cursor_wide = false, + .use_display_p3 = options.config.colorspace == .@"display-p3", + .use_linear_blending = options.config.blending.isLinear(), + .use_linear_correction = options.config.blending == .@"linear-corrected", + }, + }, + .custom_shader_uniforms = .{ + .resolution = .{ 0, 0, 1 }, + .time = 0, + .time_delta = 0, + .frame_rate = 60, // not currently updated + .frame = 0, + .channel_time = @splat(@splat(0)), // not currently updated + .channel_resolution = @splat(@splat(0)), + .mouse = @splat(0), // not currently updated + .date = @splat(0), // not currently updated + .sample_rate = 0, // N/A, we don't have any audio + .current_cursor = @splat(0), + .previous_cursor = @splat(0), + .current_cursor_color = @splat(0), + .previous_cursor_color = @splat(0), + .current_cursor_style = 0, + .previous_cursor_style = 0, + .cursor_visible = 0, + .cursor_change_time = 0, + .time_focus = 0, + .focus = 1, // assume focused initially + .palette = @splat(@splat(0)), + .background_color = @splat(0), + .foreground_color = @splat(0), + .cursor_color = @splat(0), + .cursor_text = @splat(0), + .selection_background_color = @splat(0), + .selection_foreground_color = @splat(0), + }, + .bg_image_buffer = undefined, + + // Fonts + .font_grid = options.font_grid, + .font_shaper = font_shaper, + .font_shaper_cache = font.ShaperCache.init(), + + // Shaders (initialized below) + .shaders = undefined, + + // Graphics API stuff + .api = api, + .swap_chain = swap_chain, + .display_link = display_link, + }; + + try result.initShaders(); + + // Ensure our undefined values above are correctly initialized. + result.updateFontGridUniforms(); + result.updateScreenSizeUniforms(); + result.updateBgImageBuffer(); + try result.prepBackgroundImage(); + + return result; + } + + pub fn deinit(self: *Self) void { + if (self.overlay) |*overlay| overlay.deinit(self.alloc); + self.terminal_state.deinit(self.alloc); + if (self.search_selected_match) |*m| m.arena.deinit(); + if (self.search_matches) |*m| m.arena.deinit(); + self.swap_chain.deinit(); + + if (DisplayLink != void) { + if (self.display_link) |display_link| { + display_link.stop() catch {}; + display_link.release(); + } + } + + self.cells.deinit(self.alloc); + + self.font_shaper.deinit(); + self.font_shaper_cache.deinit(self.alloc); + + self.config.deinit(); + + self.images.deinit(self.alloc); + + if (self.bg_image) |img| img.deinit(self.alloc); + + self.deinitShaders(); + + self.api.deinit(); + + self.* = undefined; + } + + fn deinitShaders(self: *Self) void { + self.shaders.deinit(self.alloc); + } + + fn initShaders(self: *Self) !void { + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + + // Load our custom shaders + const custom_shaders: []const [:0]const u8 = shadertoy.loadFromFiles( + arena_alloc, + self.config.custom_shaders, + GraphicsAPI.custom_shader_target, + ) catch |err| err: { + log.warn("error loading custom shaders err={}", .{err}); + break :err &.{}; + }; + + const has_custom_shaders = custom_shaders.len > 0; + + var shaders = try self.api.initShaders( + self.alloc, + custom_shaders, + ); + errdefer shaders.deinit(self.alloc); + + self.shaders = shaders; + self.has_custom_shaders = has_custom_shaders; + } + + /// This is called early right after surface creation. + pub fn surfaceInit(surface: *apprt.Surface) !void { + // If our API has to do things here, let it. + if (@hasDecl(GraphicsAPI, "surfaceInit")) { + try GraphicsAPI.surfaceInit(surface); + } + } + + /// This is called just prior to spinning up the renderer thread for + /// final main thread setup requirements. + pub fn finalizeSurfaceInit(self: *Self, surface: *apprt.Surface) !void { + // If our API has to do things to finalize surface init, let it. + if (@hasDecl(GraphicsAPI, "finalizeSurfaceInit")) { + try self.api.finalizeSurfaceInit(surface); + } + } + + /// Callback called by renderer.Thread when it begins. + pub fn threadEnter(self: *const Self, surface: *apprt.Surface) !void { + // If our API has to do things on thread enter, let it. + if (@hasDecl(GraphicsAPI, "threadEnter")) { + try self.api.threadEnter(surface); + } + } + + /// Callback called by renderer.Thread when it exits. + pub fn threadExit(self: *const Self) void { + // If our API has to do things on thread exit, let it. + if (@hasDecl(GraphicsAPI, "threadExit")) { + self.api.threadExit(); + } + } + + /// Called by renderer.Thread when it starts the main loop. + pub fn loopEnter(self: *Self, thr: *renderer.Thread) !void { + // If our API has to do things on loop enter, let it. + if (@hasDecl(GraphicsAPI, "loopEnter")) { + self.api.loopEnter(); + } + + // If we don't support a display link we have no work to do. + if (comptime DisplayLink == void) return; + + // This is when we know our "self" pointer is stable so we can + // setup the display link. To setup the display link we set our + // callback and we can start it immediately. + const display_link = self.display_link orelse return; + try display_link.setOutputCallback( + xev.Async, + &displayLinkCallback, + &thr.draw_now, + ); + display_link.start() catch {}; + } + + /// Called by renderer.Thread when it exits the main loop. + pub fn loopExit(self: *Self) void { + // If our API has to do things on loop exit, let it. + if (@hasDecl(GraphicsAPI, "loopExit")) { + self.api.loopExit(); + } + + // If we don't support a display link we have no work to do. + if (comptime DisplayLink == void) return; + + // Stop our display link. If this fails its okay it just means + // that we either never started it or the view its attached to + // is gone which is fine. + const display_link = self.display_link orelse return; + display_link.stop() catch {}; + } + + /// This is called by the GTK apprt after the surface is + /// reinitialized due to any of the events mentioned in + /// the doc comment for `displayUnrealized`. + pub fn displayRealized(self: *Self) !void { + // If our API has to do things on realize, let it. + if (@hasDecl(GraphicsAPI, "displayRealized")) { + self.api.displayRealized(); + } + + // Lock the draw mutex so that we can + // safely reinitialize our GPU resources. + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // We assume that the swap chain was deinited in + // `displayUnrealized`, in which case it should be + // marked defunct. If not, we have a problem. + assert(self.swap_chain.defunct); + + // We reinitialize our shaders and our swap chain. + try self.initShaders(); + self.swap_chain = try SwapChain.init( + self.api, + self.has_custom_shaders, + ); + self.reinitialize_shaders = false; + self.target_config_modified = 1; + } + + /// This is called by the GTK apprt when the surface is being destroyed. + /// This can happen because the surface is being closed but also when + /// moving the window between displays or splitting. + pub fn displayUnrealized(self: *Self) void { + // If our API has to do things on unrealize, let it. + if (@hasDecl(GraphicsAPI, "displayUnrealized")) { + self.api.displayUnrealized(); + } + + // Lock the draw mutex so that we can + // safely deinitialize our GPU resources. + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // We deinit our swap chain and shaders. + // + // This will mark them as defunct so that they + // can't be double-freed or used in draw calls. + self.swap_chain.deinit(); + self.shaders.deinit(self.alloc); + } + + fn displayLinkCallback( + _: *macos.video.DisplayLink, + ud: ?*xev.Async, + ) void { + const draw_now = ud orelse return; + draw_now.notify() catch |err| { + log.err("error notifying draw_now err={}", .{err}); + }; + } + + /// Mark the full screen as dirty so that we redraw everything. + pub inline fn markDirty(self: *Self) void { + self.terminal_state.dirty = .full; + } + + /// Called when we get an updated display ID for our display link. + pub fn setMacOSDisplayID(self: *Self, id: u32) !void { + if (comptime DisplayLink == void) return; + const display_link = self.display_link orelse return; + log.info("updating display link display id={}", .{id}); + display_link.setCurrentCGDisplay(id) catch |err| { + log.warn("error setting display link display id err={}", .{err}); + }; + } + + /// True if our renderer has animations so that a higher frequency + /// timer is used. + pub fn hasAnimations(self: *const Self) bool { + return self.has_custom_shaders; + } + + /// True if our renderer is using vsync. If true, the renderer or apprt + /// is responsible for triggering draw_now calls to the render thread. + /// That is the only way to trigger a drawFrame. + pub fn hasVsync(self: *const Self) bool { + if (comptime DisplayLink == void) return false; + const display_link = self.display_link orelse return false; + return display_link.isRunning(); + } + + /// Callback when the focus changes for the terminal this is rendering. + /// + /// Must be called on the render thread. + pub fn setFocus(self: *Self, focus: bool) !void { + assert(self.focused != focus); + + self.focused = focus; + + // Flag that we need to update our custom shaders + self.custom_shader_focused_changed = true; + + // If we're not focused, then we want to stop the display link + // because it is a waste of resources and we can move to pure + // change-driven updates. + if (comptime DisplayLink != void) link: { + const display_link = self.display_link orelse break :link; + if (focus) { + display_link.start() catch {}; + } else { + display_link.stop() catch {}; + } + } + } + + /// Callback when the window is visible or occluded. + /// + /// Must be called on the render thread. + pub fn setVisible(self: *Self, visible: bool) void { + // If we're not visible, then we want to stop the display link + // because it is a waste of resources and we can move to pure + // change-driven updates. + if (comptime DisplayLink != void) link: { + const display_link = self.display_link orelse break :link; + if (visible and self.focused) { + display_link.start() catch {}; + } else { + display_link.stop() catch {}; + } + } + } + + /// Set the new font grid. + /// + /// Must be called on the render thread. + pub fn setFontGrid(self: *Self, grid: *font.SharedGrid) void { + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // Update our grid + self.font_grid = grid; + + // Update all our textures so that they sync on the next frame. + // We can modify this without a lock because the GPU does not + // touch this data. + for (&self.swap_chain.frames) |*frame| { + frame.grayscale_modified = 0; + frame.color_modified = 0; + } + + // Get our metrics from the grid. This doesn't require a lock because + // the metrics are never recalculated. + const metrics = grid.metrics; + self.grid_metrics = metrics; + + // Reset our shaper cache. If our font changed (not just the size) then + // the data in the shaper cache may be invalid and cannot be used, so we + // always clear the cache just in case. + const font_shaper_cache = font.ShaperCache.init(); + self.font_shaper_cache.deinit(self.alloc); + self.font_shaper_cache = font_shaper_cache; + + // Update cell size. + self.size.cell = .{ + .width = metrics.cell_width, + .height = metrics.cell_height, + }; + + // Update relevant uniforms + self.updateFontGridUniforms(); + + // Force a full rebuild, because cached rows may still reference + // an outdated atlas from the old grid and this can cause garbage + // to be rendered. + self.markDirty(); + } + + /// Update uniforms that are based on the font grid. + /// + /// Caller must hold the draw mutex. + fn updateFontGridUniforms(self: *Self) void { + self.uniforms.cell_size = .{ + @floatFromInt(self.grid_metrics.cell_width), + @floatFromInt(self.grid_metrics.cell_height), + }; + } + + /// Update the frame data. + pub fn updateFrame( + self: *Self, + state: *renderer.State, + cursor_blink_visible: bool, + ) Allocator.Error!void { + // const start = std.time.Instant.now() catch unreachable; + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // log.warn( + // "[updateFrame time] start_micro={} duration={}ns", + // .{ start_micro, end.since(start) / std.time.ns_per_us }, + // ); + // } + + // We fully deinit and reset the terminal state every so often + // so that a particularly large terminal state doesn't cause + // the renderer to hold on to retained memory. + // + // Frame count is ~12 minutes at 120Hz. + const max_terminal_state_frame_count = 100_000; + if (self.terminal_state_frame_count >= max_terminal_state_frame_count) { + self.terminal_state.deinit(self.alloc); + self.terminal_state = .empty; + } + self.terminal_state_frame_count += 1; + + // Create an arena for all our temporary allocations while rebuilding + var arena = ArenaAllocator.init(self.alloc); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + + // Data we extract out of the critical area. + const Critical = struct { + links: terminal.RenderState.CellSet, + mouse: renderer.State.Mouse, + preedit: ?renderer.State.Preedit, + scrollbar: terminal.Scrollbar, + overlay_features: []const Overlay.Feature, + }; + + // Update all our data as tightly as possible within the mutex. + var critical: Critical = critical: { + // const start = try std.time.Instant.now(); + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // std.log.err("[updateFrame critical time] start={}\tduration={} us", .{ start_micro, end.since(start) / std.time.ns_per_us }); + // } + + state.mutex.lock(); + defer state.mutex.unlock(); + + // If we're in a synchronized output state, we pause all rendering. + if (state.terminal.modes.get(.synchronized_output)) { + log.debug("synchronized output started, skipping render", .{}); + return; + } + + // If scroll-to-bottom on output is enabled, check if the final line + // changed by comparing the bottom-right pin. If the node pointer or + // y offset changed, new content was added to the screen. + // Update this BEFORE we update our render state so we can + // draw the new scrolled data immediately. + if (self.config.scroll_to_bottom_on_output) scroll: { + const br = state.terminal.screens.active.pages.getBottomRight(.screen) orelse break :scroll; + + // If the pin hasn't changed, then don't scroll. + if (self.last_bottom_node == @intFromPtr(br.node) and + self.last_bottom_y == br.y) break :scroll; + + // Update tracked pin state for next frame + self.last_bottom_node = @intFromPtr(br.node); + self.last_bottom_y = br.y; + + // Scroll + state.terminal.scrollViewport(.bottom); + } + + // Update our terminal state + try self.terminal_state.update(self.alloc, state.terminal); + + // If our terminal state is dirty at all we need to redo + // the viewport search. + if (self.terminal_state.dirty != .false) { + state.terminal.flags.search_viewport_dirty = true; + } + + // Get our scrollbar out of the terminal. We synchronize + // the scrollbar read with frame data updates because this + // naturally limits the number of calls to this method (it + // can be expensive) and also makes it so we don't need another + // cross-thread mailbox message within the IO path. + const scrollbar = state.terminal.screens.active.pages.scrollbar(); + + // Get our preedit state + const preedit: ?renderer.State.Preedit = preedit: { + const p = state.preedit orelse break :preedit null; + break :preedit try p.clone(arena_alloc); + }; + + // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. + // We only do this if the Kitty image state is dirty meaning only if + // it changes. + // + // If we have any virtual references, we must also rebuild our + // kitty state on every frame because any cell change can move + // an image. + if (self.images.kittyRequiresUpdate(state.terminal)) { + // We need to grab the draw mutex since this updates + // our image state that drawFrame uses. + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + self.images.kittyUpdate( + self.alloc, + state.terminal, + .{ + .width = self.grid_metrics.cell_width, + .height = self.grid_metrics.cell_height, + }, + ); + } + + // Get our OSC8 links we're hovering if we have a mouse. + // This requires terminal state because of URLs. + const links: terminal.RenderState.CellSet = osc8: { + // If our mouse isn't hovering, we have no links. + const vp = state.mouse.point orelse break :osc8 .empty; + + // If the right mods aren't pressed, then we can't match. + if (!state.mouse.mods.equal(inputpkg.ctrlOrSuper(.{}))) + break :osc8 .empty; + + break :osc8 self.terminal_state.linkCells( + arena_alloc, + vp, + ) catch |err| { + log.warn("error searching for OSC8 links err={}", .{err}); + break :osc8 .empty; + }; + }; + + const overlay_features: []const Overlay.Feature = overlay: { + const insp = state.inspector orelse break :overlay &.{}; + const renderer_info = insp.rendererInfo(); + break :overlay renderer_info.overlayFeatures( + arena_alloc, + ) catch &.{}; + }; + + break :critical .{ + .links = links, + .mouse = state.mouse, + .preedit = preedit, + .scrollbar = scrollbar, + .overlay_features = overlay_features, + }; + }; + + // Outside the critical area we can update our links to contain + // our regex results. + self.config.links.renderCellMap( + arena_alloc, + &critical.links, + &self.terminal_state, + state.mouse.point, + state.mouse.mods, + ) catch |err| { + log.warn("error searching for regex links err={}", .{err}); + }; + + // Clear our highlight state and update. + if (self.search_matches_dirty or self.terminal_state.dirty != .false) { + self.search_matches_dirty = false; + + // Clear the prior highlights + const row_data = self.terminal_state.row_data.slice(); + var any_dirty: bool = false; + for ( + row_data.items(.highlights), + row_data.items(.dirty), + ) |*highlights, *dirty| { + if (highlights.items.len > 0) { + highlights.clearRetainingCapacity(); + dirty.* = true; + any_dirty = true; + } + } + if (any_dirty and self.terminal_state.dirty == .false) { + self.terminal_state.dirty = .partial; + } + + // NOTE: The order below matters. Highlights added earlier + // will take priority. + + if (self.search_selected_match) |m| { + self.terminal_state.updateHighlightsFlattened( + self.alloc, + @intFromEnum(HighlightTag.search_match_selected), + &.{m.match}, + ) catch |err| { + // Not a critical error, we just won't show highlights. + log.warn("error updating search selected highlight err={}", .{err}); + }; + } + + if (self.search_matches) |m| { + self.terminal_state.updateHighlightsFlattened( + self.alloc, + @intFromEnum(HighlightTag.search_match), + m.matches, + ) catch |err| { + // Not a critical error, we just won't show highlights. + log.warn("error updating search highlights err={}", .{err}); + }; + } + } + + // From this point forward no more errors. + errdefer comptime unreachable; + + // Reset our dirty state after updating. + defer self.terminal_state.dirty = .false; + + // Rebuild the overlay image if we have one. We can do this + // outside of any critical areas. + self.rebuildOverlay( + critical.overlay_features, + ) catch |err| { + log.warn( + "error rebuilding overlay surface err={}", + .{err}, + ); + }; + + // Acquire the draw mutex for all remaining state updates. + { + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // Build our GPU cells + self.rebuildCells( + critical.preedit, + renderer.cursorStyle(&self.terminal_state, .{ + .preedit = critical.preedit != null, + .focused = self.focused, + .blink_visible = cursor_blink_visible, + }), + &critical.links, + ) catch |err| { + // This means we weren't able to allocate our buffer + // to update the cells. In this case, we continue with + // our old buffer (frozen contents) and log it. + comptime assert(@TypeOf(err) == error{OutOfMemory}); + log.warn("error rebuilding GPU cells err={}", .{err}); + }; + + // The scrollbar is only emitted during draws so we also + // check the scrollbar cache here and update if needed. + // This is pretty fast. + if (!self.scrollbar.eql(critical.scrollbar)) { + self.scrollbar = critical.scrollbar; + self.scrollbar_dirty = true; + } + + // Update our background color + self.uniforms.bg_color = .{ + self.terminal_state.colors.background.r, + self.terminal_state.colors.background.g, + self.terminal_state.colors.background.b, + @intFromFloat(@round(self.config.background_opacity * 255.0)), + }; + + // If we're on macOS and have glass styles, we remove + // the background opacity because the glass effect handles + // it. + if (comptime builtin.os.tag == .macos) switch (self.config.background_blur) { + .@"macos-glass-regular", + .@"macos-glass-clear", + => self.uniforms.bg_color[3] = 0, + + else => {}, + }; + + // Prepare our overlay image for upload (or unload). This + // has to use our general allocator since it modifies + // state that survives frames. + self.images.overlayUpdate( + self.alloc, + self.overlay, + ) catch |err| { + log.warn("error updating overlay images err={}", .{err}); + }; + + // Update custom shader uniforms that depend on terminal state. + self.updateCustomShaderUniformsFromState(); + } + + // Notify our shaper we're done for the frame. For some shapers, + // such as CoreText, this triggers off-thread cleanup logic. + self.font_shaper.endFrame(); + } + + /// Draw the frame to the screen. + /// + /// If `sync` is true, this will synchronously block until + /// the frame is finished drawing and has been presented. + pub fn drawFrame( + self: *Self, + sync: bool, + ) !void { + // const start = std.time.Instant.now() catch unreachable; + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // log.warn( + // "[drawFrame time] start_micro={} duration={}ns", + // .{ start_micro, end.since(start) / std.time.ns_per_us }, + // ); + // } + + // We hold a the draw mutex to prevent changes to any + // data we access while we're in the middle of drawing. + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // After the graphics API is complete (so we defer) we want to + // update our scrollbar state. + defer if (self.scrollbar_dirty) { + // Fail instantly if the surface mailbox if full, we'll just + // get it on the next frame. + if (self.surface_mailbox.push(.{ + .scrollbar = self.scrollbar, + }, .instant) > 0) self.scrollbar_dirty = false; + }; + + // Let our graphics API do any bookkeeping, etc. + // that it needs to do before / after `drawFrame`. + self.api.drawFrameStart(); + defer self.api.drawFrameEnd(); + + // Retrieve the most up-to-date surface size from the Graphics API + const surface_size = try self.api.surfaceSize(); + + // If either of our surface dimensions is zero + // then drawing is absurd, so we just return. + if (surface_size.width == 0 or surface_size.height == 0) return; + + const size_changed = + self.size.screen.width != surface_size.width or + self.size.screen.height != surface_size.height; + + // Conditions under which we need to draw the frame, otherwise we + // don't need to since the previous frame should be identical. + const needs_redraw = + size_changed or + self.cells_rebuilt or + self.hasAnimations() or + sync; + + if (!needs_redraw) { + // We still need to present the last target again, because the + // apprt may be swapping buffers and display an outdated frame + // if we don't draw something new. + try self.api.presentLastTarget(); + return; + } + self.cells_rebuilt = false; + + // Wait for a frame to be available. + const frame = try self.swap_chain.nextFrame(); + errdefer self.swap_chain.releaseFrame(); + // log.debug("drawing frame index={}", .{self.swap_chain.frame_index}); + + // If we need to reinitialize our shaders, do so. + if (self.reinitialize_shaders) { + self.reinitialize_shaders = false; + self.shaders.deinit(self.alloc); + try self.initShaders(); + } + + // Our shaders should not be defunct at this point. + assert(!self.shaders.defunct); + + // If we have custom shaders, make sure we have the + // custom shader state in our frame state, otherwise + // if we have a state but don't need it we remove it. + if (self.has_custom_shaders) { + if (frame.custom_shader_state == null) { + frame.custom_shader_state = try .init(self.api); + try frame.custom_shader_state.?.resize( + self.api, + surface_size.width, + surface_size.height, + ); + } + } else if (frame.custom_shader_state) |*state| { + state.deinit(); + frame.custom_shader_state = null; + } + + // If our stored size doesn't match the + // surface size we need to update it. + if (size_changed) { + self.size.screen = .{ + .width = surface_size.width, + .height = surface_size.height, + }; + self.updateScreenSizeUniforms(); + } + + // If this frame's target isn't the correct size, or the target + // config has changed (such as when the blending mode changes), + // remove it and replace it with a new one with the right values. + if (frame.target.width != self.size.screen.width or + frame.target.height != self.size.screen.height or + frame.target_config_modified != self.target_config_modified) + { + try frame.resize( + self.api, + self.size.screen.width, + self.size.screen.height, + ); + frame.target_config_modified = self.target_config_modified; + } + + // Upload images to the GPU as necessary. + _ = self.images.upload(self.alloc, &self.api); + + // Upload the background image to the GPU as necessary. + try self.uploadBackgroundImage(); + + // Update per-frame custom shader uniforms. + try self.updateCustomShaderUniformsForFrame(); + + // Setup our frame data + try frame.uniforms.sync(&.{self.uniforms}); + try frame.cells_bg.sync(self.cells.bg_cells); + const fg_count = try frame.cells.syncFromArrayLists(self.cells.fg_rows.lists); + + // If our background image buffer has changed, sync it. + if (frame.bg_image_buffer_modified != self.bg_image_buffer_modified) { + try frame.bg_image_buffer.sync(&.{self.bg_image_buffer}); + + frame.bg_image_buffer_modified = self.bg_image_buffer_modified; + } + + // If our font atlas changed, sync the texture data + texture: { + const modified = self.font_grid.atlas_grayscale.modified.load(.monotonic); + if (modified <= frame.grayscale_modified) break :texture; + self.font_grid.lock.lockShared(); + defer self.font_grid.lock.unlockShared(); + frame.grayscale_modified = self.font_grid.atlas_grayscale.modified.load(.monotonic); + try self.syncAtlasTexture(&self.font_grid.atlas_grayscale, &frame.grayscale); + } + texture: { + const modified = self.font_grid.atlas_color.modified.load(.monotonic); + if (modified <= frame.color_modified) break :texture; + self.font_grid.lock.lockShared(); + defer self.font_grid.lock.unlockShared(); + frame.color_modified = self.font_grid.atlas_color.modified.load(.monotonic); + try self.syncAtlasTexture(&self.font_grid.atlas_color, &frame.color); + } + + // Get a frame context from the graphics API. + var frame_ctx = try self.api.beginFrame(self, &frame.target); + defer frame_ctx.complete(sync); + + { + var pass = frame_ctx.renderPass(&.{.{ + .target = if (frame.custom_shader_state) |state| + .{ .texture = state.back_texture } + else + .{ .target = frame.target }, + .clear_color = .{ 0.0, 0.0, 0.0, 0.0 }, + }}); + defer pass.complete(); + + // First we draw our background image, if we have one. + // The bg image shader also draws the main bg color. + // + // Otherwise, if we don't have a background image, we + // draw the background color by itself in its own step. + // + // NOTE: We don't use the clear_color for this because that + // would require us to do color space conversion on the + // CPU-side. In the future when we have utilities for + // that we should remove this step and use clear_color. + if (self.bg_image) |img| switch (img) { + .ready => |texture| pass.step(.{ + .pipeline = self.shaders.pipelines.bg_image, + .uniforms = frame.uniforms.buffer, + .buffers = &.{frame.bg_image_buffer.buffer}, + .textures = &.{texture}, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }), + else => {}, + } else { + pass.step(.{ + .pipeline = self.shaders.pipelines.bg_color, + .uniforms = frame.uniforms.buffer, + .buffers = &.{ null, frame.cells_bg.buffer }, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }); + } + + // Then we draw any kitty images that need + // to be behind text AND cell backgrounds. + self.images.draw( + &self.api, + self.shaders.pipelines.image, + &pass, + .kitty_below_bg, + ); + + // Then we draw any opaque cell backgrounds. + pass.step(.{ + .pipeline = self.shaders.pipelines.cell_bg, + .uniforms = frame.uniforms.buffer, + .buffers = &.{ null, frame.cells_bg.buffer }, + .draw = .{ .type = .triangle, .vertex_count = 3 }, + }); + + // Kitty images between cell backgrounds and text. + self.images.draw( + &self.api, + self.shaders.pipelines.image, + &pass, + .kitty_below_text, + ); + + // Text. + pass.step(.{ + .pipeline = self.shaders.pipelines.cell_text, + .uniforms = frame.uniforms.buffer, + .buffers = &.{ + frame.cells.buffer, + frame.cells_bg.buffer, + }, + .textures = &.{ + frame.grayscale, + frame.color, + }, + .draw = .{ + .type = .triangle_strip, + .vertex_count = 4, + .instance_count = fg_count, + }, + }); + + // Kitty images in front of text. + self.images.draw( + &self.api, + self.shaders.pipelines.image, + &pass, + .kitty_above_text, + ); + + // Debug overlay. We do this before any custom shader state + // because our debug overlay is aligned with the grid. + if (self.overlay != null) self.images.draw( + &self.api, + self.shaders.pipelines.image, + &pass, + .overlay, + ); + } + + // If we have custom shaders, then we render them. + if (frame.custom_shader_state) |*state| { + // Sync our uniforms. + try state.uniforms.sync(&.{self.custom_shader_uniforms}); + + for (self.shaders.post_pipelines, 0..) |pipeline, i| { + defer state.swap(); + + var pass = frame_ctx.renderPass(&.{.{ + .target = if (i < self.shaders.post_pipelines.len - 1) + .{ .texture = state.front_texture } + else + .{ .target = frame.target }, + .clear_color = .{ 0.0, 0.0, 0.0, 0.0 }, + }}); + defer pass.complete(); + + pass.step(.{ + .pipeline = pipeline, + .uniforms = state.uniforms.buffer, + .textures = &.{state.back_texture}, + .samplers = &.{state.sampler}, + .draw = .{ + .type = .triangle, + .vertex_count = 3, + }, + }); + } + } + } + + // Callback from the graphics API when a frame is completed. + pub fn frameCompleted( + self: *Self, + health: Health, + ) void { + // If our health value hasn't changed, then we do nothing. We don't + // do a cmpxchg here because strict atomicity isn't important. + if (self.health.load(.seq_cst) != health) { + self.health.store(health, .seq_cst); + + // Our health value changed, so we notify the surface so that it + // can do something about it. + _ = self.surface_mailbox.push(.{ + .renderer_health = health, + }, .{ .forever = {} }); + } + + // Always release our semaphore + self.swap_chain.releaseFrame(); + } + + /// Call this any time the background image path changes. + /// + /// Caller must hold the draw mutex. + fn prepBackgroundImage(self: *Self) !void { + // Then we try to load the background image if we have a path. + if (self.config.bg_image) |p| load_background: { + const path = switch (p) { + .required, .optional => |slice| slice, + }; + + // Open the file + var file = std.fs.openFileAbsolute(path, .{}) catch |err| { + log.warn( + "error opening background image file \"{s}\": {}", + .{ path, err }, + ); + break :load_background; + }; + defer file.close(); + + // Read it + const contents = file.readToEndAlloc( + self.alloc, + std.math.maxInt(u32), // Max size of 4 GiB, for now. + ) catch |err| { + log.warn( + "error reading background image file \"{s}\": {}", + .{ path, err }, + ); + break :load_background; + }; + defer self.alloc.free(contents); + + // Figure out what type it probably is. + const file_type = switch (FileType.detect(contents)) { + .unknown => FileType.guessFromExtension( + std.fs.path.extension(path), + ), + else => |t| t, + }; + + // Decode it if we know how. + const image_data = switch (file_type) { + .png => try wuffs.png.decode(self.alloc, contents), + .jpeg => try wuffs.jpeg.decode(self.alloc, contents), + .unknown => { + log.warn( + "Cannot determine file type for background image file \"{s}\"!", + .{path}, + ); + break :load_background; + }, + else => |f| { + log.warn( + "Unsupported file type {} for background image file \"{s}\"!", + .{ f, path }, + ); + break :load_background; + }, + }; + + const image: imagepkg.Image = .{ + .pending = .{ + .width = image_data.width, + .height = image_data.height, + .pixel_format = .rgba, + .data = image_data.data.ptr, + }, + }; + + // If we have an existing background image, replace it. + // Otherwise, set this as our background image directly. + if (self.bg_image) |*img| { + img.markForReplace(self.alloc, image); + } else { + self.bg_image = image; + } + } else { + // If we don't have a background image path, mark our + // background image for unload if we currently have one. + if (self.bg_image) |*img| img.markForUnload(); + } + } + + fn uploadBackgroundImage(self: *Self) !void { + // Make sure our bg image is uploaded if it needs to be. + if (self.bg_image) |*bg| { + if (bg.isUnloading()) { + bg.deinit(self.alloc); + self.bg_image = null; + return; + } + if (bg.isPending()) try bg.upload(self.alloc, &self.api); + } + } + + /// Update the configuration. + pub fn changeConfig(self: *Self, config: *DerivedConfig) !void { + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // We always redo the font shaper in case font features changed. We + // could check to see if there was an actual config change but this is + // easier and rare enough to not cause performance issues. + { + var font_shaper = try font.Shaper.init(self.alloc, .{ + .features = config.font_features.items, + }); + errdefer font_shaper.deinit(); + self.font_shaper.deinit(); + self.font_shaper = font_shaper; + } + + // We also need to reset the shaper cache so shaper info + // from the previous font isn't reused for the new font. + const font_shaper_cache = font.ShaperCache.init(); + self.font_shaper_cache.deinit(self.alloc); + self.font_shaper_cache = font_shaper_cache; + + // Set our new minimum contrast + self.uniforms.min_contrast = config.min_contrast; + + // Set our new color space and blending + self.uniforms.bools.use_display_p3 = config.colorspace == .@"display-p3"; + self.uniforms.bools.use_linear_blending = config.blending.isLinear(); + self.uniforms.bools.use_linear_correction = config.blending == .@"linear-corrected"; + + const bg_image_config_changed = + self.config.bg_image_fit != config.bg_image_fit or + self.config.bg_image_position != config.bg_image_position or + self.config.bg_image_repeat != config.bg_image_repeat or + self.config.bg_image_opacity != config.bg_image_opacity; + + const bg_image_changed = + if (self.config.bg_image) |old| + if (config.bg_image) |new| + !old.equal(new) + else + true + else + config.bg_image != null; + + const old_blending = self.config.blending; + const custom_shaders_changed = !self.config.custom_shaders.equal(config.custom_shaders); + + self.config.deinit(); + self.config = config.*; + + // If our background image path changed, prepare the new bg image. + if (bg_image_changed) try self.prepBackgroundImage(); + + // If our background image config changed, update the vertex buffer. + if (bg_image_config_changed) self.updateBgImageBuffer(); + + // Reset our viewport to force a rebuild, in case of a font change. + self.markDirty(); + + const blending_changed = old_blending != config.blending; + + if (blending_changed) { + // We update our API's blending mode. + self.api.blending = config.blending; + // And indicate that we need to reinitialize our shaders. + self.reinitialize_shaders = true; + // And indicate that our swap chain targets need to + // be re-created to account for the new blending mode. + self.target_config_modified +%= 1; + } + + if (custom_shaders_changed) { + self.reinitialize_shaders = true; + } + } + + /// Resize the screen. + pub fn setScreenSize( + self: *Self, + size: renderer.Size, + ) void { + self.draw_mutex.lock(); + defer self.draw_mutex.unlock(); + + // We only actually need the padding from this, + // everything else is derived elsewhere. + self.size.padding = size.padding; + + self.updateScreenSizeUniforms(); + + log.debug("screen size size={}", .{size}); + } + + /// Update uniforms that are based on the screen size. + /// + /// Caller must hold the draw mutex. + fn updateScreenSizeUniforms(self: *Self) void { + const terminal_size = self.size.terminal(); + + // Blank space around the grid. + const blank: renderer.Padding = self.size.screen.blankPadding( + self.size.padding, + .{ + .columns = self.cells.size.columns, + .rows = self.cells.size.rows, + }, + .{ + .width = self.grid_metrics.cell_width, + .height = self.grid_metrics.cell_height, + }, + ).add(self.size.padding); + + // Setup our uniforms + self.uniforms.projection_matrix = math.ortho2d( + -1 * @as(f32, @floatFromInt(self.size.padding.left)), + @floatFromInt(terminal_size.width + self.size.padding.right), + @floatFromInt(terminal_size.height + self.size.padding.bottom), + -1 * @as(f32, @floatFromInt(self.size.padding.top)), + ); + self.uniforms.grid_padding = .{ + @floatFromInt(blank.top), + @floatFromInt(blank.right), + @floatFromInt(blank.bottom), + @floatFromInt(blank.left), + }; + self.uniforms.screen_size = .{ + @floatFromInt(self.size.screen.width), + @floatFromInt(self.size.screen.height), + }; + } + + /// Update the background image vertex buffer (CPU-side). + /// + /// This should be called if and when configs change that + /// could affect the background image. + /// + /// Caller must hold the draw mutex. + fn updateBgImageBuffer(self: *Self) void { + self.bg_image_buffer = .{ + .opacity = self.config.bg_image_opacity, + .info = .{ + .position = switch (self.config.bg_image_position) { + .@"top-left" => .tl, + .@"top-center" => .tc, + .@"top-right" => .tr, + .@"center-left" => .ml, + .@"center-center", .center => .mc, + .@"center-right" => .mr, + .@"bottom-left" => .bl, + .@"bottom-center" => .bc, + .@"bottom-right" => .br, + }, + .fit = switch (self.config.bg_image_fit) { + .contain => .contain, + .cover => .cover, + .stretch => .stretch, + .none => .none, + }, + .repeat = self.config.bg_image_repeat, + }, + }; + // Signal that the buffer was modified. + self.bg_image_buffer_modified +%= 1; + } + + /// Update custom shader uniforms that depend on terminal state. + /// + /// This should be called in `updateFrame` when terminal state changes. + fn updateCustomShaderUniformsFromState(self: *Self) void { + // We only need to do this if we have custom shaders. + if (!self.has_custom_shaders) return; + + // Only update when terminal state is dirty. + if (self.terminal_state.dirty == .false) return; + + const uniforms: *shadertoy.Uniforms = &self.custom_shader_uniforms; + const colors: *const terminal.RenderState.Colors = &self.terminal_state.colors; + + // 256-color palette + for (colors.palette, 0..) |color, i| { + uniforms.palette[i] = .{ + @as(f32, @floatFromInt(color.r)) / 255.0, + @as(f32, @floatFromInt(color.g)) / 255.0, + @as(f32, @floatFromInt(color.b)) / 255.0, + 1.0, + }; + } + + // Background color + uniforms.background_color = .{ + @as(f32, @floatFromInt(colors.background.r)) / 255.0, + @as(f32, @floatFromInt(colors.background.g)) / 255.0, + @as(f32, @floatFromInt(colors.background.b)) / 255.0, + 1.0, + }; + + // Foreground color + uniforms.foreground_color = .{ + @as(f32, @floatFromInt(colors.foreground.r)) / 255.0, + @as(f32, @floatFromInt(colors.foreground.g)) / 255.0, + @as(f32, @floatFromInt(colors.foreground.b)) / 255.0, + 1.0, + }; + + // Cursor color + if (colors.cursor) |cursor_color| { + uniforms.cursor_color = .{ + @as(f32, @floatFromInt(cursor_color.r)) / 255.0, + @as(f32, @floatFromInt(cursor_color.g)) / 255.0, + @as(f32, @floatFromInt(cursor_color.b)) / 255.0, + 1.0, + }; + } + + // NOTE: the following could be optimized to follow a change in + // config for a slight optimization however this is only 12 bytes + // each being updated and likely isn't a cause for concern + + // Cursor text color + if (self.config.cursor_text) |cursor_text| { + uniforms.cursor_text = .{ + @as(f32, @floatFromInt(cursor_text.color.r)) / 255.0, + @as(f32, @floatFromInt(cursor_text.color.g)) / 255.0, + @as(f32, @floatFromInt(cursor_text.color.b)) / 255.0, + 1.0, + }; + } + + // Selection background color + if (self.config.selection_background) |selection_bg| { + uniforms.selection_background_color = .{ + @as(f32, @floatFromInt(selection_bg.color.r)) / 255.0, + @as(f32, @floatFromInt(selection_bg.color.g)) / 255.0, + @as(f32, @floatFromInt(selection_bg.color.b)) / 255.0, + 1.0, + }; + } + + // Selection foreground color + if (self.config.selection_foreground) |selection_fg| { + uniforms.selection_foreground_color = .{ + @as(f32, @floatFromInt(selection_fg.color.r)) / 255.0, + @as(f32, @floatFromInt(selection_fg.color.g)) / 255.0, + @as(f32, @floatFromInt(selection_fg.color.b)) / 255.0, + 1.0, + }; + } + + // Cursor visibility + uniforms.cursor_visible = @intFromBool(self.terminal_state.cursor.visible); + + // Cursor style + const cursor_style: renderer.CursorStyle = .fromTerminal(self.terminal_state.cursor.visual_style); + uniforms.previous_cursor_style = uniforms.current_cursor_style; + uniforms.current_cursor_style = @as(i32, @intFromEnum(cursor_style)); + } + + /// Update per-frame custom shader uniforms. + /// + /// This should be called exactly once per frame, inside `drawFrame`. + fn updateCustomShaderUniformsForFrame(self: *Self) !void { + // We only need to do this if we have custom shaders. + if (!self.has_custom_shaders) return; + + const uniforms: *shadertoy.Uniforms = &self.custom_shader_uniforms; + + const now = try std.time.Instant.now(); + defer self.last_frame_time = now; + const first_frame_time = self.first_frame_time orelse t: { + self.first_frame_time = now; + break :t now; + }; + const last_frame_time = self.last_frame_time orelse now; + + const since_ns: f32 = @floatFromInt(now.since(first_frame_time)); + uniforms.time = since_ns / std.time.ns_per_s; + + const delta_ns: f32 = @floatFromInt(now.since(last_frame_time)); + uniforms.time_delta = delta_ns / std.time.ns_per_s; + + uniforms.frame += 1; + + const screen = self.size.screen; + const padding = self.size.padding; + const cell = self.size.cell; + + uniforms.resolution = .{ + @floatFromInt(screen.width), + @floatFromInt(screen.height), + 1, + }; + uniforms.channel_resolution[0] = .{ + @floatFromInt(screen.width), + @floatFromInt(screen.height), + 1, + 0, + }; + + if (self.cells.getCursorGlyph()) |cursor| { + const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]); + const cursor_height: f32 = @floatFromInt(cursor.glyph_size[1]); + + // Left edge of the cell the cursor is in. + var pixel_x: f32 = @floatFromInt( + cursor.grid_pos[0] * cell.width + padding.left, + ); + // Top edge, relative to the top of the + // screen, of the cell the cursor is in. + var pixel_y: f32 = @floatFromInt( + cursor.grid_pos[1] * cell.height + padding.top, + ); + + // If +Y is up in our shaders, we need to flip the coordinate + // so that it's instead the top edge of the cell relative to + // the *bottom* of the screen. + if (!GraphicsAPI.custom_shader_y_is_down) { + pixel_y = @as(f32, @floatFromInt(screen.height)) - pixel_y; + } + + // Add the X bearing to get the -X (left) edge of the cursor. + pixel_x += @floatFromInt(cursor.bearings[0]); + + // How we deal with the Y bearing depends on which direction + // is "up", since we want our final `pixel_y` value to be the + // +Y edge of the cursor. + if (GraphicsAPI.custom_shader_y_is_down) { + // As a reminder, the Y bearing is the distance from the + // bottom of the cell to the top of the glyph, so to get + // the +Y edge we need to add the cell height, subtract + // the Y bearing, and add the glyph height to get the +Y + // (bottom) edge of the cursor. + pixel_y += @floatFromInt(cell.height); + pixel_y -= @floatFromInt(cursor.bearings[1]); + pixel_y += @floatFromInt(cursor.glyph_size[1]); + } else { + // If the Y direction is reversed though, we instead want + // the *top* edge of the cursor, which means we just need + // to subtract the cell height and add the Y bearing. + pixel_y -= @floatFromInt(cell.height); + pixel_y += @floatFromInt(cursor.bearings[1]); + } + + const new_cursor: [4]f32 = .{ + pixel_x, + pixel_y, + cursor_width, + cursor_height, + }; + const cursor_color: [4]f32 = .{ + @as(f32, @floatFromInt(cursor.color[0])) / 255.0, + @as(f32, @floatFromInt(cursor.color[1])) / 255.0, + @as(f32, @floatFromInt(cursor.color[2])) / 255.0, + @as(f32, @floatFromInt(cursor.color[3])) / 255.0, + }; + + const cursor_changed: bool = + !std.meta.eql(new_cursor, uniforms.current_cursor) or + !std.meta.eql(cursor_color, uniforms.current_cursor_color); + + if (cursor_changed) { + uniforms.previous_cursor = uniforms.current_cursor; + uniforms.previous_cursor_color = uniforms.current_cursor_color; + uniforms.current_cursor = new_cursor; + uniforms.current_cursor_color = cursor_color; + uniforms.cursor_change_time = uniforms.time; + } + } + + // Update focus uniforms + uniforms.focus = @intFromBool(self.focused); + + // If we need to update the time our focus state changed + // then update it to our current frame time. This may not be + // exactly correct since it is frame time, not exact focus + // time, but focus time on its own isn't exactly correct anyways + // since it comes async from a message. + if (self.custom_shader_focused_changed and self.focused) { + uniforms.time_focus = uniforms.time; + self.custom_shader_focused_changed = false; + } + } + + /// Build the overlay as configured. Returns null if there is no + /// overlay currently configured. + fn rebuildOverlay( + self: *Self, + features: []const Overlay.Feature, + ) Overlay.InitError!void { + // const start = std.time.Instant.now() catch unreachable; + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // log.warn( + // "[rebuildOverlay time] start_micro={} duration={}ns", + // .{ start_micro, end.since(start) / std.time.ns_per_us }, + // ); + // } + + const alloc = self.alloc; + + // If we have no features enabled, don't build an overlay. + // If we had a previous overlay, deallocate it. + if (features.len == 0) { + if (self.overlay) |*old| { + old.deinit(alloc); + self.overlay = null; + } + + return; + } + + // If we had a previous overlay, clear it. Otherwise, init. + const overlay: *Overlay = overlay: { + if (self.overlay) |*v| existing: { + // Verify that our overlay size matches our screen + // size as we know it now. If not, deinit and reinit. + // Note: these intCasts are always safe because z2d + // stores as i32 but we always init with a u32. + const width: u32 = @intCast(v.surface.getWidth()); + const height: u32 = @intCast(v.surface.getHeight()); + const term_size = self.size.terminal(); + if (width != term_size.width or + height != term_size.height) break :existing; + + // We also depend on cell size. + if (v.cell_size.width != self.size.cell.width or + v.cell_size.height != self.size.cell.height) break :existing; + + // Everything matches, so we can just reset the surface + // and redraw. + v.reset(); + break :overlay v; + } + + // If we reached this point we want to reset our overlay. + if (self.overlay) |*v| { + v.deinit(alloc); + self.overlay = null; + } + + assert(self.overlay == null); + const new: Overlay = try .init(alloc, self.size); + self.overlay = new; + break :overlay &self.overlay.?; + }; + overlay.applyFeatures( + alloc, + &self.terminal_state, + features, + ); + } + + const PreeditRange = struct { + y: terminal.size.CellCountInt, + x: [2]terminal.size.CellCountInt, + cp_offset: usize, + }; + + /// Convert the terminal state to GPU cells stored in CPU memory. These + /// are then synced to the GPU in the next frame. This only updates CPU + /// memory and doesn't touch the GPU. + /// + /// This requires the draw mutex. + /// + /// Dirty state on terminal state won't be reset by this. + fn rebuildCells( + self: *Self, + preedit: ?renderer.State.Preedit, + cursor_style_: ?renderer.CursorStyle, + links: *const terminal.RenderState.CellSet, + ) Allocator.Error!void { + const state: *terminal.RenderState = &self.terminal_state; + + // const start = try std.time.Instant.now(); + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // // "[rebuildCells time] <START us>\t<TIME_TAKEN us>" + // std.log.warn("[rebuildCells time] {}\t{}", .{start_micro, end.since(start) / std.time.ns_per_us}); + // } + + const grid_size_diff = + self.cells.size.rows != state.rows or + self.cells.size.columns != state.cols; + + if (grid_size_diff) { + var new_size = self.cells.size; + new_size.rows = state.rows; + new_size.columns = state.cols; + try self.cells.resize(self.alloc, new_size); + + // Update our uniforms accordingly, otherwise + // our background cells will be out of place. + self.uniforms.grid_size = .{ new_size.columns, new_size.rows }; + } + + const rebuild = state.dirty == .full or grid_size_diff; + if (rebuild) { + // If we are doing a full rebuild, then we clear the entire cell buffer. + self.cells.reset(); + + // We also reset our padding extension depending on the screen type + switch (self.config.padding_color) { + .background => {}, + + // For extension, assume we are extending in all directions. + // For "extend" this may be disabled due to heuristics below. + .extend, .@"extend-always" => { + self.uniforms.padding_extend = .{ + .up = true, + .down = true, + .left = true, + .right = true, + }; + }, + } + } + + // From this point on we never fail. We produce some kind of + // working terminal state, even if incorrect. + errdefer comptime unreachable; + + // Get our row data from our state + const row_data = state.row_data.slice(); + const row_raws = row_data.items(.raw); + const row_cells = row_data.items(.cells); + const row_dirty = row_data.items(.dirty); + const row_selection = row_data.items(.selection); + const row_highlights = row_data.items(.highlights); + + // If our cell contents buffer is shorter than the screen viewport, + // we render the rows that fit, starting from the bottom. If instead + // the viewport is shorter than the cell contents buffer, we align + // the top of the viewport with the top of the contents buffer. + const row_len: usize = @min( + state.rows, + self.cells.size.rows, + ); + + // Determine our x/y range for preedit. We don't want to render anything + // here because we will render the preedit separately. + const preedit_range: ?PreeditRange = if (preedit) |preedit_v| preedit: { + // We base the preedit on the position of the cursor in the + // viewport. If the cursor isn't visible in the viewport we + // don't show it. + const cursor_vp = state.cursor.viewport orelse + break :preedit null; + + // If our preedit row isn't dirty then we don't need the + // preedit range. This also avoids an issue later where we + // unconditionally add preedit cells when this is set. + if (!rebuild and !row_dirty[cursor_vp.y]) break :preedit null; + + const range = preedit_v.range( + cursor_vp.x, + state.cols - 1, + ); + break :preedit .{ + .y = @intCast(cursor_vp.y), + .x = .{ range.start, range.end }, + .cp_offset = range.cp_offset, + }; + } else null; + + for ( + 0.., + row_raws[0..row_len], + row_cells[0..row_len], + row_dirty[0..row_len], + row_selection[0..row_len], + row_highlights[0..row_len], + ) |y_usize, row, *cells, *dirty, selection, *highlights| { + const y: terminal.size.CellCountInt = @intCast(y_usize); + + if (!rebuild) { + // Only rebuild if we are doing a full rebuild or this row is dirty. + if (!dirty.*) continue; + + // Clear the cells if the row is dirty + self.cells.clear(y); + } + + // Unmark the dirty state in our render state. + dirty.* = false; + + self.rebuildRow( + y, + row, + cells, + preedit_range, + selection, + highlights, + links, + ) catch |err| { + // This should never happen except under exceptional + // scenarios. In this case, we don't want to corrupt + // our render state so just clear this row and keep + // trying to finish it out. + log.warn("error building row y={} err={}", .{ y, err }); + self.cells.clear(y); + }; + } + + // Setup our cursor rendering information. + cursor: { + // Clear our cursor by default. + self.cells.setCursor(null, null); + self.uniforms.cursor_pos = .{ + std.math.maxInt(u16), + std.math.maxInt(u16), + }; + + // If the cursor isn't visible on the viewport, don't show + // a cursor. Otherwise, get our cursor cell, because we may + // need it for styling. + const cursor_vp = state.cursor.viewport orelse break :cursor; + const cursor_style: terminal.Style = cursor_style: { + const cells = state.row_data.items(.cells); + const cell = cells[cursor_vp.y].get(cursor_vp.x); + break :cursor_style if (cell.raw.hasStyling()) + cell.style + else + .{}; + }; + + // If we have preedit text, we don't setup a cursor + if (preedit != null) break :cursor; + + // If there isn't a cursor visual style requested then + // we don't render a cursor. + const style = cursor_style_ orelse break :cursor; + + // Determine the cursor color. + const cursor_color = cursor_color: { + // If an explicit cursor color was set by OSC 12, use that. + if (state.colors.cursor) |v| break :cursor_color v; + + // Use our configured color if specified + if (self.config.cursor_color) |v| switch (v) { + .color => |color| break :cursor_color color.toTerminalRGB(), + + inline .@"cell-foreground", + .@"cell-background", + => |_, tag| { + const fg_style = cursor_style.fg(.{ + .default = state.colors.foreground, + .palette = &state.colors.palette, + .bold = self.config.bold_color, + }); + const bg_style = cursor_style.bg( + &state.cursor.cell, + &state.colors.palette, + ) orelse state.colors.background; + + break :cursor_color switch (tag) { + .color => unreachable, + .@"cell-foreground" => if (cursor_style.flags.inverse) + bg_style + else + fg_style, + .@"cell-background" => if (cursor_style.flags.inverse) + fg_style + else + bg_style, + }; + }, + }; + + break :cursor_color state.colors.foreground; + }; + + self.addCursor( + &state.cursor, + style, + cursor_color, + ); + + // If the cursor is visible then we set our uniforms. + if (style == .block) { + const wide = state.cursor.cell.wide; + + self.uniforms.cursor_pos = .{ + // If we are a spacer tail of a wide cell, our cursor needs + // to move back one cell. The saturate is to ensure we don't + // overflow but this shouldn't happen with well-formed input. + switch (wide) { + .narrow, .spacer_head, .wide => cursor_vp.x, + .spacer_tail => cursor_vp.x -| 1, + }, + @intCast(cursor_vp.y), + }; + + self.uniforms.bools.cursor_wide = switch (wide) { + .narrow, .spacer_head => false, + .wide, .spacer_tail => true, + }; + + const uniform_color = if (self.config.cursor_text) |txt| blk: { + // If cursor-text is set, then compute the correct color. + // Otherwise, use the background color. + if (txt == .color) { + // Use the color set by cursor-text, if any. + break :blk txt.color.toTerminalRGB(); + } + + const fg_style = cursor_style.fg(.{ + .default = state.colors.foreground, + .palette = &state.colors.palette, + .bold = self.config.bold_color, + }); + const bg_style = cursor_style.bg( + &state.cursor.cell, + &state.colors.palette, + ) orelse state.colors.background; + + break :blk switch (txt) { + // If the cell is reversed, use the opposite cell color instead. + .@"cell-foreground" => if (cursor_style.flags.inverse) + bg_style + else + fg_style, + .@"cell-background" => if (cursor_style.flags.inverse) + fg_style + else + bg_style, + else => unreachable, + }; + } else state.colors.background; + + self.uniforms.cursor_color = .{ + uniform_color.r, + uniform_color.g, + uniform_color.b, + 255, + }; + } + } + + // Setup our preedit text. + if (preedit) |preedit_v| preedit: { + const range = preedit_range orelse break :preedit; + var x = range.x[0]; + for (preedit_v.codepoints[range.cp_offset..]) |cp| { + self.addPreeditCell( + cp, + .{ .x = x, .y = range.y }, + state.colors.foreground, + ) catch |err| { + log.warn("error building preedit cell, will be invalid x={} y={}, err={}", .{ + x, + range.y, + err, + }); + }; + + x += if (cp.wide) 2 else 1; + } + } + + // Update that our cells rebuilt + self.cells_rebuilt = true; + + // Log some things + // log.debug("rebuildCells complete cached_runs={}", .{ + // self.font_shaper_cache.count(), + // }); + } + + fn rebuildRow( + self: *Self, + y: terminal.size.CellCountInt, + row: terminal.page.Row, + cells: *std.MultiArrayList(terminal.RenderState.Cell), + preedit_range: ?PreeditRange, + selection: ?[2]terminal.size.CellCountInt, + highlights: *const std.ArrayList(terminal.RenderState.Highlight), + links: *const terminal.RenderState.CellSet, + ) !void { + const state = &self.terminal_state; + + // If our viewport is wider than our cell contents buffer, + // we still only process cells up to the width of the buffer. + const cells_slice = cells.slice(); + const cells_len = @min(cells_slice.len, self.cells.size.columns); + const cells_raw = cells_slice.items(.raw); + const cells_style = cells_slice.items(.style); + + // On primary screen, we still apply vertical padding + // extension under certain conditions we feel are safe. + // + // This helps make some scenarios look better while + // avoiding scenarios we know do NOT look good. + switch (self.config.padding_color) { + // These already have the correct values set above. + .background, .@"extend-always" => {}, + + // Apply heuristics for padding extension. + .extend => if (y == 0) { + self.uniforms.padding_extend.up = !rowNeverExtendBg( + row, + cells_raw, + cells_style, + &state.colors.palette, + state.colors.background, + ); + } else if (y == self.cells.size.rows - 1) { + self.uniforms.padding_extend.down = !rowNeverExtendBg( + row, + cells_raw, + cells_style, + &state.colors.palette, + state.colors.background, + ); + }, + } + + // Iterator of runs for shaping. + var run_iter_opts: font.shape.RunOptions = .{ + .grid = self.font_grid, + .cells = cells_slice, + .selection = if (selection) |s| s else null, + + // We want to do font shaping as long as the cursor is + // visible on this viewport. + .cursor_x = cursor_x: { + const vp = state.cursor.viewport orelse break :cursor_x null; + if (vp.y != y) break :cursor_x null; + break :cursor_x vp.x; + }, + }; + run_iter_opts.applyBreakConfig(self.config.font_shaping_break); + var run_iter = self.font_shaper.runIterator(run_iter_opts); + var shaper_run: ?font.shape.TextRun = try run_iter.next(self.alloc); + var shaper_cells: ?[]const font.shape.Cell = null; + var shaper_cells_i: usize = 0; + + for ( + 0.., + cells_raw[0..cells_len], + cells_style[0..cells_len], + ) |x, *cell, *managed_style| { + // If this cell falls within our preedit range then we + // skip this because preedits are setup separately. + if (preedit_range) |range| preedit: { + // We're not on the preedit line, no actions necessary. + if (range.y != y) break :preedit; + // We're before the preedit range, no actions necessary. + if (x < range.x[0]) break :preedit; + // We're in the preedit range, skip this cell. + if (x <= range.x[1]) continue; + // After exiting the preedit range we need to catch + // the run position up because of the missed cells. + // In all other cases, no action is necessary. + if (x != range.x[1] + 1) break :preedit; + + // Step the run iterator until we find a run that ends + // after the current cell, which will be the soonest run + // that might contain glyphs for our cell. + while (shaper_run) |run| { + if (run.offset + run.cells > x) break; + shaper_run = try run_iter.next(self.alloc); + shaper_cells = null; + shaper_cells_i = 0; + } + + const run = shaper_run orelse break :preedit; + + // If we haven't shaped this run, do so now. + shaper_cells = shaper_cells orelse + // Try to read the cells from the shaping cache if we can. + self.font_shaper_cache.get(run) orelse + cache: { + // Otherwise we have to shape them. + const new_cells = try self.font_shaper.shape(run); + + // Try to cache them. If caching fails for any reason we + // continue because it is just a performance optimization, + // not a correctness issue. + self.font_shaper_cache.put( + self.alloc, + run, + new_cells, + ) catch |err| { + log.warn( + "error caching font shaping results err={}", + .{err}, + ); + }; + + // The cells we get from direct shaping are always owned + // by the shaper and valid until the next shaping call so + // we can safely use them. + break :cache new_cells; + }; + + // Advance our index until we reach or pass + // our current x position in the shaper cells. + const shaper_cells_unwrapped = shaper_cells.?; + while (run.offset + shaper_cells_unwrapped[shaper_cells_i].x < x) { + shaper_cells_i += 1; + } + } + + const wide = cell.wide; + const style: terminal.Style = if (cell.hasStyling()) + managed_style.* + else + .{}; + + // True if this cell is selected + const selected: enum { + false, + selection, + search, + search_selected, + } = selected: { + // Order below matters for precedence. + + // Selection should take the highest precedence. + const x_compare = if (wide == .spacer_tail) + x -| 1 + else + x; + if (selection) |sel| { + if (x_compare >= sel[0] and + x_compare <= sel[1]) break :selected .selection; + } + + // If we're highlighted, then we're selected. In the + // future we want to use a different style for this + // but this to get started. + for (highlights.items) |hl| { + if (x_compare >= hl.range[0] and + x_compare <= hl.range[1]) + { + const tag: HighlightTag = @enumFromInt(hl.tag); + break :selected switch (tag) { + .search_match => .search, + .search_match_selected => .search_selected, + }; + } + } + + break :selected .false; + }; + + // The `_style` suffixed values are the colors based on + // the cell style (SGR), before applying any additional + // configuration, inversions, selections, etc. + const bg_style = style.bg( + cell, + &state.colors.palette, + ); + const fg_style = style.fg(.{ + .default = state.colors.foreground, + .palette = &state.colors.palette, + .bold = self.config.bold_color, + }); + + // The final background color for the cell. + const bg = switch (selected) { + // If we have an explicit selection background color + // specified in the config, use that. + // + // If no configuration, then our selection background + // is our foreground color. + .selection => if (self.config.selection_background) |v| switch (v) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + } else state.colors.foreground, + + .search => switch (self.config.search_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, + + .search_selected => switch (self.config.search_selected_background) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) bg_style else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else bg_style, + }, + + // Not selected + .false => if (style.flags.inverse != isCovering(cell.codepoint())) + // Two cases cause us to invert (use the fg color as the bg) + // - The "inverse" style flag. + // - A "covering" glyph; we use fg for bg in that + // case to help make sure that padding extension + // works correctly. + // + // If one of these is true (but not the other) + // then we use the fg style color for the bg. + fg_style + else + // Otherwise they cancel out. + bg_style, + }; + + const fg = fg: { + // Our happy-path non-selection background color + // is our style or our configured defaults. + const final_bg = bg_style orelse state.colors.background; + + // Whether we need to use the bg color as our fg color: + // - Cell is selected, inverted, and set to cell-foreground + // - Cell is selected, not inverted, and set to cell-background + // - Cell is inverted and not selected + break :fg switch (selected) { + .selection => if (self.config.selection_foreground) |v| switch (v) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + } else state.colors.background, + + .search => switch (self.config.search_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, + + .search_selected => switch (self.config.search_selected_foreground) { + .color => |color| color.toTerminalRGB(), + .@"cell-foreground" => if (style.flags.inverse) final_bg else fg_style, + .@"cell-background" => if (style.flags.inverse) fg_style else final_bg, + }, + + .false => if (style.flags.inverse) + final_bg + else + fg_style, + }; + }; + + // Foreground alpha for this cell. + const alpha: u8 = if (style.flags.faint) self.config.faint_opacity else 255; + + // Set the cell's background color. + { + const rgb = bg orelse state.colors.background; + + // Determine our background alpha. If we have transparency configured + // then this is dynamic depending on some situations. This is all + // in an attempt to make transparency look the best for various + // situations. See inline comments. + const bg_alpha: u8 = bg_alpha: { + const default: u8 = 255; + + // Cells that are selected should be fully opaque. + if (selected != .false) break :bg_alpha default; + + // Cells that are reversed should be fully opaque. + if (style.flags.inverse) break :bg_alpha default; + + // If the user requested to have opacity on all cells, apply it. + if (self.config.background_opacity_cells and bg_style != null) { + var opacity: f64 = @floatFromInt(default); + opacity *= self.config.background_opacity; + break :bg_alpha @intFromFloat(opacity); + } + + // Cells that have an explicit bg color should be fully opaque. + if (bg_style != null) break :bg_alpha default; + + // Otherwise, we won't draw the bg for this cell, + // we'll let the already-drawn background color + // show through. + break :bg_alpha 0; + }; + + self.cells.bgCell(y, x).* = .{ + rgb.r, rgb.g, rgb.b, bg_alpha, + }; + } + + // If the invisible flag is set on this cell then we + // don't need to render any foreground elements, so + // we just skip all glyphs with this x coordinate. + // + // NOTE: This behavior matches xterm. Some other terminal + // emulators, e.g. Alacritty, still render text decorations + // and only make the text itself invisible. The decision + // has been made here to match xterm's behavior for this. + if (style.flags.invisible) { + continue; + } + + // Give links a single underline, unless they already have + // an underline, in which case use a double underline to + // distinguish them. + const underline: terminal.Attribute.Underline = underline: { + if (links.contains(.{ + .x = @intCast(x), + .y = @intCast(y), + })) { + break :underline if (style.flags.underline == .single) + .double + else + .single; + } + break :underline style.flags.underline; + }; + + // We draw underlines first so that they layer underneath text. + // This improves readability when a colored underline is used + // which intersects parts of the text (descenders). + if (underline != .none) self.addUnderline( + @intCast(x), + @intCast(y), + underline, + style.underlineColor(&state.colors.palette) orelse fg, + alpha, + ) catch |err| { + log.warn( + "error adding underline to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + + if (style.flags.overline) self.addOverline(@intCast(x), @intCast(y), fg, alpha) catch |err| { + log.warn( + "error adding overline to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + + // If we're at or past the end of our shaper run then + // we need to get the next run from the run iterator. + if (shaper_cells != null and shaper_cells_i >= shaper_cells.?.len) { + shaper_run = try run_iter.next(self.alloc); + shaper_cells = null; + shaper_cells_i = 0; + } + + if (shaper_run) |run| glyphs: { + // If we haven't shaped this run yet, do so. + shaper_cells = shaper_cells orelse + // Try to read the cells from the shaping cache if we can. + self.font_shaper_cache.get(run) orelse + cache: { + // Otherwise we have to shape them. + const new_cells = try self.font_shaper.shape(run); + + // Try to cache them. If caching fails for any reason we + // continue because it is just a performance optimization, + // not a correctness issue. + self.font_shaper_cache.put( + self.alloc, + run, + new_cells, + ) catch |err| { + log.warn( + "error caching font shaping results err={}", + .{err}, + ); + }; + + // The cells we get from direct shaping are always owned + // by the shaper and valid until the next shaping call so + // we can safely use them. + break :cache new_cells; + }; + + const shaped_cells = shaper_cells orelse break :glyphs; + + // If there are no shaper cells for this run, ignore it. + // This can occur for runs of empty cells, and is fine. + if (shaped_cells.len == 0) break :glyphs; + + // If we encounter a shaper cell to the left of the current + // cell then we have some problems. This logic relies on x + // position monotonically increasing. + assert(run.offset + shaped_cells[shaper_cells_i].x >= x); + + // NOTE: An assumption is made here that a single cell will never + // be present in more than one shaper run. If that assumption is + // violated, this logic breaks. + + while (shaper_cells_i < shaped_cells.len and + run.offset + shaped_cells[shaper_cells_i].x == x) : ({ + shaper_cells_i += 1; + }) { + self.addGlyph( + @intCast(x), + @intCast(y), + state.cols, + cells_raw, + shaped_cells[shaper_cells_i], + shaper_run.?, + fg, + alpha, + ) catch |err| { + log.warn( + "error adding glyph to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + } + } + + // Finally, draw a strikethrough if necessary. + if (style.flags.strikethrough) self.addStrikethrough( + @intCast(x), + @intCast(y), + fg, + alpha, + ) catch |err| { + log.warn( + "error adding strikethrough to cell, will be invalid x={} y={}, err={}", + .{ x, y, err }, + ); + }; + } + } + + /// Add an underline decoration to the specified cell + fn addUnderline( + self: *Self, + x: terminal.size.CellCountInt, + y: terminal.size.CellCountInt, + style: terminal.Attribute.Underline, + color: terminal.color.RGB, + alpha: u8, + ) !void { + const sprite: font.Sprite = switch (style) { + .none => unreachable, + .single => .underline, + .double => .underline_double, + .dotted => .underline_dotted, + .dashed => .underline_dashed, + .curly => .underline_curly, + }; + + const render = try self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(sprite), + .{ + .cell_width = 1, + .grid_metrics = self.grid_metrics, + }, + ); + + try self.cells.add(self.alloc, .underline, .{ + .atlas = .grayscale, + .grid_pos = .{ @intCast(x), @intCast(y) }, + .color = .{ color.r, color.g, color.b, alpha }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x), + @intCast(render.glyph.offset_y), + }, + }); + } + + /// Add a overline decoration to the specified cell + fn addOverline( + self: *Self, + x: terminal.size.CellCountInt, + y: terminal.size.CellCountInt, + color: terminal.color.RGB, + alpha: u8, + ) !void { + const render = try self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(font.Sprite.overline), + .{ + .cell_width = 1, + .grid_metrics = self.grid_metrics, + }, + ); + + try self.cells.add(self.alloc, .overline, .{ + .atlas = .grayscale, + .grid_pos = .{ @intCast(x), @intCast(y) }, + .color = .{ color.r, color.g, color.b, alpha }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x), + @intCast(render.glyph.offset_y), + }, + }); + } + + /// Add a strikethrough decoration to the specified cell + fn addStrikethrough( + self: *Self, + x: terminal.size.CellCountInt, + y: terminal.size.CellCountInt, + color: terminal.color.RGB, + alpha: u8, + ) !void { + const render = try self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(font.Sprite.strikethrough), + .{ + .cell_width = 1, + .grid_metrics = self.grid_metrics, + }, + ); + + try self.cells.add(self.alloc, .strikethrough, .{ + .atlas = .grayscale, + .grid_pos = .{ @intCast(x), @intCast(y) }, + .color = .{ color.r, color.g, color.b, alpha }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x), + @intCast(render.glyph.offset_y), + }, + }); + } + + // Add a glyph to the specified cell. + fn addGlyph( + self: *Self, + x: terminal.size.CellCountInt, + y: terminal.size.CellCountInt, + cols: usize, + cell_raws: []const terminal.page.Cell, + shaper_cell: font.shape.Cell, + shaper_run: font.shape.TextRun, + color: terminal.color.RGB, + alpha: u8, + ) !void { + const cell = cell_raws[x]; + const cp = cell.codepoint(); + + // Render + const render = try self.font_grid.renderGlyph( + self.alloc, + shaper_run.font_index, + shaper_cell.glyph_index, + .{ + .grid_metrics = self.grid_metrics, + .thicken = self.config.font_thicken, + .thicken_strength = self.config.font_thicken_strength, + .cell_width = cell.gridWidth(), + // If there's no Nerd Font constraint for this codepoint + // then, if it's a symbol, we constrain it to fit inside + // its cell(s), we don't modify the alignment at all. + .constraint = getConstraint(cp) orelse + if (cellpkg.isSymbol(cp)) .{ + .size = .fit, + } else .none, + .constraint_width = constraintWidth( + cell_raws, + x, + cols, + ), + }, + ); + + // If the glyph is 0 width or height, it will be invisible + // when drawn, so don't bother adding it to the buffer. + if (render.glyph.width == 0 or render.glyph.height == 0) { + return; + } + + try self.cells.add(self.alloc, .text, .{ + .atlas = switch (render.presentation) { + .emoji => .color, + .text => .grayscale, + }, + .bools = .{ .no_min_contrast = noMinContrast(cp) }, + .grid_pos = .{ @intCast(x), @intCast(y) }, + .color = .{ color.r, color.g, color.b, alpha }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x + shaper_cell.x_offset), + @intCast(render.glyph.offset_y + shaper_cell.y_offset), + }, + }); + } + + fn addCursor( + self: *Self, + cursor_state: *const terminal.RenderState.Cursor, + cursor_style: renderer.CursorStyle, + cursor_color: terminal.color.RGB, + ) void { + const cursor_vp = cursor_state.viewport orelse return; + + // Add the cursor. We render the cursor over the wide character if + // we're on the wide character tail. + const wide, const x = cell: { + // The cursor goes over the screen cursor position. + if (!cursor_vp.wide_tail) break :cell .{ + cursor_state.cell.wide == .wide, + cursor_vp.x, + }; + + // If we're part of a wide character, we move the cursor back + // to the actual character. + break :cell .{ true, cursor_vp.x - 1 }; + }; + + const alpha: u8 = if (!self.focused) 255 else alpha: { + const alpha = 255 * self.config.cursor_opacity; + break :alpha @intFromFloat(@ceil(alpha)); + }; + + const render = switch (cursor_style) { + .block, + .block_hollow, + .bar, + .underline, + => render: { + const sprite: font.Sprite = switch (cursor_style) { + .block => .cursor_rect, + .block_hollow => .cursor_hollow_rect, + .bar => .cursor_bar, + .underline => .cursor_underline, + .lock => unreachable, + }; + + break :render self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(sprite), + .{ + .cell_width = if (wide) 2 else 1, + .grid_metrics = self.grid_metrics, + }, + ) catch |err| { + log.warn("error rendering cursor glyph err={}", .{err}); + return; + }; + }, + + .lock => self.font_grid.renderCodepoint( + self.alloc, + 0xF023, // lock symbol + .regular, + .text, + .{ + .cell_width = if (wide) 2 else 1, + .grid_metrics = self.grid_metrics, + }, + ) catch |err| { + log.warn("error rendering cursor glyph err={}", .{err}); + return; + } orelse { + // This should never happen because we embed nerd + // fonts so we just log and return instead of fallback. + log.warn("failed to find lock symbol for cursor codepoint=0xF023", .{}); + return; + }, + }; + + self.cells.setCursor(.{ + .atlas = .grayscale, + .bools = .{ .is_cursor_glyph = true }, + .grid_pos = .{ x, cursor_vp.y }, + .color = .{ cursor_color.r, cursor_color.g, cursor_color.b, alpha }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x), + @intCast(render.glyph.offset_y), + }, + }, cursor_style); + } + + fn addPreeditCell( + self: *Self, + cp: renderer.State.Preedit.Codepoint, + coord: terminal.Coordinate, + screen_fg: terminal.color.RGB, + ) !void { + // Render the glyph for our preedit text + const render_ = self.font_grid.renderCodepoint( + self.alloc, + @intCast(cp.codepoint), + .regular, + .text, + .{ .grid_metrics = self.grid_metrics }, + ) catch |err| { + log.warn("error rendering preedit glyph err={}", .{err}); + return; + }; + const render = render_ orelse { + log.warn("failed to find font for preedit codepoint={X}", .{cp.codepoint}); + return; + }; + + // Add our text + try self.cells.add(self.alloc, .text, .{ + .atlas = .grayscale, + .grid_pos = .{ @intCast(coord.x), @intCast(coord.y) }, + .color = .{ screen_fg.r, screen_fg.g, screen_fg.b, 255 }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x), + @intCast(render.glyph.offset_y), + }, + }); + + // Add underline + try self.addUnderline(@intCast(coord.x), @intCast(coord.y), .single, screen_fg, 255); + if (cp.wide and coord.x < self.cells.size.columns - 1) { + try self.addUnderline(@intCast(coord.x + 1), @intCast(coord.y), .single, screen_fg, 255); + } + } + + /// Sync the atlas data to the given texture. This copies the bytes + /// associated with the atlas to the given texture. If the atlas no + /// longer fits into the texture, the texture will be resized. + fn syncAtlasTexture( + self: *const Self, + atlas: *const font.Atlas, + texture: *Texture, + ) !void { + if (atlas.size > texture.width) { + // Free our old texture + texture.*.deinit(); + + // Reallocate + texture.* = try self.api.initAtlasTexture(atlas); + } + + try texture.replaceRegion(0, 0, atlas.size, atlas.size, atlas.data); + } + }; +} diff --git a/vendor/libghostty-vt/src/renderer/image.zig b/vendor/libghostty-vt/src/renderer/image.zig new file mode 100644 index 00000000..c43d2798 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/image.zig @@ -0,0 +1,958 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../quirks.zig").inlineAssert; +const wuffs = @import("wuffs"); +const terminal = @import("../terminal/main.zig"); + +const Renderer = @import("../renderer.zig").Renderer; +const GraphicsAPI = Renderer.API; +const Texture = GraphicsAPI.Texture; +const CellSize = @import("size.zig").CellSize; +const Overlay = @import("Overlay.zig"); + +const log = std.log.scoped(.renderer_image); + +/// Generic image rendering state for the renderer. This stores all +/// images and their placements and exposes only a limited public API +/// for adding images and placements and drawing them. +pub const State = struct { + /// The full image state for the renderer that specifies what images + /// need to be uploaded, pruned, etc. + images: ImageMap, + + /// The placements for the Kitty image protocol. + kitty_placements: std.ArrayListUnmanaged(Placement), + + /// The end index (exclusive) for placements that should be + /// drawn below the background, below the text, etc. + kitty_bg_end: u32, + kitty_text_end: u32, + + /// True if there are any virtual placements. This needs to be known + /// because virtual placements need to be recalculated more often + /// on frame builds and are generally more expensive to handle. + kitty_virtual: bool, + + /// Overlays + overlay_placements: std.ArrayListUnmanaged(Placement), + + pub const empty: State = .{ + .images = .empty, + .kitty_placements = .empty, + .kitty_bg_end = 0, + .kitty_text_end = 0, + .kitty_virtual = false, + .overlay_placements = .empty, + }; + + pub fn deinit(self: *State, alloc: Allocator) void { + { + var it = self.images.iterator(); + while (it.next()) |kv| kv.value_ptr.image.deinit(alloc); + self.images.deinit(alloc); + } + self.kitty_placements.deinit(alloc); + self.overlay_placements.deinit(alloc); + } + + /// Upload any images to the GPU that need to be uploaded, + /// and remove any images that are no longer needed on the GPU. + /// + /// If any uploads fail, they are ignored. The return value + /// can be used to detect if upload was a total success (true) + /// or not (false). + pub fn upload( + self: *State, + alloc: Allocator, + api: *GraphicsAPI, + ) bool { + var success: bool = true; + var image_it = self.images.iterator(); + while (image_it.next()) |kv| { + const img = &kv.value_ptr.image; + if (img.isUnloading()) { + img.deinit(alloc); + self.images.removeByPtr(kv.key_ptr); + continue; + } + + if (img.isPending()) { + img.upload( + alloc, + api, + ) catch |err| { + log.warn("error uploading image to GPU err={}", .{err}); + success = false; + }; + } + } + + return success; + } + + pub const DrawPlacements = enum { + kitty_below_bg, + kitty_below_text, + kitty_above_text, + overlay, + }; + + /// Draw the given named set of placements. + /// + /// Any placements that have non-uploaded images are ignored. Any + /// graphics API errors during drawing are also ignored. + pub fn draw( + self: *State, + api: *GraphicsAPI, + pipeline: GraphicsAPI.Pipeline, + pass: *GraphicsAPI.RenderPass, + placement_type: DrawPlacements, + ) void { + const placements: []const Placement = switch (placement_type) { + .kitty_below_bg => self.kitty_placements.items[0..self.kitty_bg_end], + .kitty_below_text => self.kitty_placements.items[self.kitty_bg_end..self.kitty_text_end], + .kitty_above_text => self.kitty_placements.items[self.kitty_text_end..], + .overlay => self.overlay_placements.items, + }; + + for (placements) |p| { + // Look up the image + const image = self.images.get(p.image_id) orelse { + log.warn("image not found for placement image_id={}", .{p.image_id}); + continue; + }; + + // Get the texture + const texture = switch (image.image) { + .ready, + .unload_ready, + => |t| t, + else => { + log.warn("image not ready for placement image_id={}", .{p.image_id}); + continue; + }, + }; + + // Create our vertex buffer, which is always exactly one item. + // future(mitchellh): we can group rendering multiple instances of a single image + var buf = GraphicsAPI.Buffer(GraphicsAPI.shaders.Image).initFill( + api.imageBufferOptions(), + &.{.{ + .grid_pos = .{ + @as(f32, @floatFromInt(p.x)), + @as(f32, @floatFromInt(p.y)), + }, + + .cell_offset = .{ + @as(f32, @floatFromInt(p.cell_offset_x)), + @as(f32, @floatFromInt(p.cell_offset_y)), + }, + + .source_rect = .{ + @as(f32, @floatFromInt(p.source_x)), + @as(f32, @floatFromInt(p.source_y)), + @as(f32, @floatFromInt(p.source_width)), + @as(f32, @floatFromInt(p.source_height)), + }, + + .dest_size = .{ + @as(f32, @floatFromInt(p.width)), + @as(f32, @floatFromInt(p.height)), + }, + }}, + ) catch |err| { + log.warn("error creating image vertex buffer err={}", .{err}); + continue; + }; + defer buf.deinit(); + + pass.step(.{ + .pipeline = pipeline, + .buffers = &.{buf.buffer}, + .textures = &.{texture}, + .draw = .{ + .type = .triangle_strip, + .vertex_count = 4, + }, + }); + } + } + + /// Update our overlay state. Null value deletes any existing overlay. + pub fn overlayUpdate( + self: *State, + alloc: Allocator, + overlay_: ?Overlay, + ) !void { + const overlay = overlay_ orelse { + // If we don't have an overlay, remove any existing one. + if (self.images.getPtr(.overlay)) |data| { + data.image.markForUnload(); + } + return; + }; + + // For transmit time we always just use the current time + // and overwrite the overlay. + const transmit_time = try std.time.Instant.now(); + + // Ensure we have space for our overlay placement. Do this before + // we upload our image so we don't have to deal with cleaning + // that up. + self.overlay_placements.clearRetainingCapacity(); + try self.overlay_placements.ensureUnusedCapacity(alloc, 1); + + // Setup our image. + const pending = overlay.pendingImage(); + try self.prepImage( + alloc, + .overlay, + transmit_time, + pending, + ); + errdefer comptime unreachable; + + // Setup our placement + self.overlay_placements.appendAssumeCapacity(.{ + .image_id = .overlay, + .x = 0, + .y = 0, + .z = 0, + .width = pending.width, + .height = pending.height, + .cell_offset_x = 0, + .cell_offset_y = 0, + .source_x = 0, + .source_y = 0, + .source_width = pending.width, + .source_height = pending.height, + }); + } + + /// Returns true if the Kitty graphics state requires an update based + /// on the terminal state and our internal state. + /// + /// This does not read/write state used by drawing. + pub fn kittyRequiresUpdate( + self: *const State, + t: *const terminal.Terminal, + ) bool { + // If the terminal kitty image state is dirty, we must update. + if (t.screens.active.kitty_images.dirty) return true; + + // If we have any virtual references, we must also rebuild our + // kitty state on every frame because any cell change can move + // an image. If the virtual placements were removed, this will + // be set to false on the next update. + if (self.kitty_virtual) return true; + + return false; + } + + /// Update the Kitty graphics state from the terminal. + /// + /// This reads/writes state used by drawing. + pub fn kittyUpdate( + self: *State, + alloc: Allocator, + t: *const terminal.Terminal, + cell_size: CellSize, + ) void { + const storage = &t.screens.active.kitty_images; + defer storage.dirty = false; + + // We always clear our previous placements no matter what because + // we rebuild them from scratch. + self.kitty_placements.clearRetainingCapacity(); + self.kitty_virtual = false; + + // Go through our known images and if there are any that are no longer + // in use then mark them to be freed. + // + // This never conflicts with the below because a placement can't + // reference an image that doesn't exist. + { + var it = self.images.iterator(); + while (it.next()) |kv| { + switch (kv.key_ptr.*) { + // We're only looking at Kitty images + .kitty => |id| if (storage.imageById(id) == null) { + kv.value_ptr.image.markForUnload(); + }, + + .overlay => {}, + } + } + } + + // The top-left and bottom-right corners of our viewport in screen + // points. This lets us determine offsets and containment of placements. + const top = t.screens.active.pages.getTopLeft(.viewport); + const bot = t.screens.active.pages.getBottomRight(.viewport).?; + const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y; + const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y; + + // Go through the placements and ensure the image is + // on the GPU or else is ready to be sent to the GPU. + var it = storage.placements.iterator(); + while (it.next()) |kv| { + const p = kv.value_ptr; + + // Special logic based on location + switch (p.location) { + .pin => {}, + .virtual => { + // We need to mark virtual placements on our renderer so that + // we know to rebuild in more scenarios since cell changes can + // now trigger placement changes. + self.kitty_virtual = true; + + // We also continue out because virtual placements are + // only triggered by the unicode placeholder, not by the + // placement itself. + continue; + }, + } + + // Get the image for the placement + const image = storage.imageById(kv.key_ptr.image_id) orelse { + log.warn( + "missing image for placement, ignoring image_id={}", + .{kv.key_ptr.image_id}, + ); + continue; + }; + + self.prepKittyPlacement( + alloc, + t, + top_y, + bot_y, + &image, + p, + ) catch |err| { + // For errors we log and continue. We try to place + // other placements even if one fails. + log.warn("error preparing kitty placement err={}", .{err}); + }; + } + + // If we have virtual placements then we need to scan for placeholders. + if (self.kitty_virtual) { + var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot); + while (v_it.next()) |virtual_p| { + self.prepKittyVirtualPlacement( + alloc, + t, + &virtual_p, + cell_size, + ) catch |err| { + // For errors we log and continue. We try to place + // other placements even if one fails. + log.warn("error preparing kitty placement err={}", .{err}); + }; + } + } + + // Sort the placements by their Z value. + std.mem.sortUnstable( + Placement, + self.kitty_placements.items, + {}, + struct { + fn lessThan( + ctx: void, + lhs: Placement, + rhs: Placement, + ) bool { + _ = ctx; + return lhs.z < rhs.z or + (lhs.z == rhs.z and lhs.image_id.zLessThan(rhs.image_id)); + } + }.lessThan, + ); + + // Find our indices. The values are sorted by z so we can + // find the first placement out of bounds to find the limits. + const bg_limit = std.math.minInt(i32) / 2; + var bg_end: ?u32 = null; + var text_end: ?u32 = null; + for (self.kitty_placements.items, 0..) |p, i| { + if (bg_end == null and p.z >= bg_limit) bg_end = @intCast(i); + if (text_end == null and p.z >= 0) text_end = @intCast(i); + } + + // If we didn't see any images with a z > the bg limit, + // then our bg end is the end of our placement list. + self.kitty_bg_end = + bg_end orelse @intCast(self.kitty_placements.items.len); + // Same idea for the image_text_end. + self.kitty_text_end = + text_end orelse @intCast(self.kitty_placements.items.len); + } + + const PrepImageError = error{ + OutOfMemory, + ImageConversionError, + }; + + /// Get the viewport-relative position for this + /// placement and add it to the placements list. + fn prepKittyPlacement( + self: *State, + alloc: Allocator, + t: *const terminal.Terminal, + top_y: u32, + bot_y: u32, + image: *const terminal.kitty.graphics.Image, + p: *const terminal.kitty.graphics.ImageStorage.Placement, + ) PrepImageError!void { + // Get the rect for the placement. If this placement doesn't have + // a rect then its virtual or something so skip it. + const rect = p.rect(image.*, t) orelse return; + + // This is expensive but necessary. + const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; + + // If the selection isn't within our viewport then skip it. + if (img_top_y > bot_y) return; + if (img_bot_y < top_y) return; + + // We need to prep this image for upload if it isn't in the + // cache OR it is in the cache but the transmit time doesn't + // match meaning this image is different. + try self.prepKittyImage(alloc, image); + + // Calculate the dimensions of our image, taking in to + // account the rows / columns specified by the placement. + const dest_size = p.calculatedSize(image.*, t); + + // Calculate the source rectangle + const source_x = @min(image.width, p.source_x); + const source_y = @min(image.height, p.source_y); + const source_width = if (p.source_width > 0) + @min(image.width - source_x, p.source_width) + else + image.width; + const source_height = if (p.source_height > 0) + @min(image.height - source_y, p.source_height) + else + image.height; + + // Get the viewport-relative Y position of the placement. + const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); + + // Accumulate the placement + if (dest_size.width > 0 and dest_size.height > 0) { + try self.kitty_placements.append(alloc, .{ + .image_id = .{ .kitty = image.id }, + .x = @intCast(rect.top_left.x), + .y = y_pos, + .z = p.z, + .width = dest_size.width, + .height = dest_size.height, + .cell_offset_x = p.x_offset, + .cell_offset_y = p.y_offset, + .source_x = source_x, + .source_y = source_y, + .source_width = source_width, + .source_height = source_height, + }); + } + } + + fn prepKittyVirtualPlacement( + self: *State, + alloc: Allocator, + t: *const terminal.Terminal, + p: *const terminal.kitty.graphics.unicode.Placement, + cell_size: CellSize, + ) PrepImageError!void { + const storage = &t.screens.active.kitty_images; + const image = storage.imageById(p.image_id) orelse { + log.warn( + "missing image for virtual placement, ignoring image_id={}", + .{p.image_id}, + ); + return; + }; + + const rp = p.renderPlacement( + storage, + &image, + cell_size.width, + cell_size.height, + ) catch |err| { + log.warn("error rendering virtual placement err={}", .{err}); + return; + }; + + // If our placement is zero sized then we don't do anything. + if (rp.dest_width == 0 or rp.dest_height == 0) return; + + const viewport: terminal.point.Point = t.screens.active.pages.pointFromPin( + .viewport, + rp.top_left, + ) orelse { + // This is unreachable with virtual placements because we should + // only ever be looking at virtual placements that are in our + // viewport in the renderer and virtual placements only ever take + // up one row. + unreachable; + }; + + // Prepare the image for the GPU and store the placement. + try self.prepKittyImage(alloc, &image); + try self.kitty_placements.append(alloc, .{ + .image_id = .{ .kitty = image.id }, + .x = @intCast(rp.top_left.x), + .y = @intCast(viewport.viewport.y), + .z = -1, + .width = rp.dest_width, + .height = rp.dest_height, + .cell_offset_x = rp.offset_x, + .cell_offset_y = rp.offset_y, + .source_x = rp.source_x, + .source_y = rp.source_y, + .source_width = rp.source_width, + .source_height = rp.source_height, + }); + } + + /// Prepare an image for upload to the GPU. + fn prepImage( + self: *State, + alloc: Allocator, + id: Id, + transmit_time: std.time.Instant, + pending: Image.Pending, + ) PrepImageError!void { + // If this image exists and its transmit time is the same we assume + // it is the identical image so we don't need to send it to the GPU. + const gop = try self.images.getOrPut(alloc, id); + if (gop.found_existing and + gop.value_ptr.transmit_time.order(transmit_time) == .eq) + { + return; + } + + // Copy the data so we own it. + const data = if (alloc.dupe( + u8, + pending.dataSlice(), + )) |v| v else |_| { + if (!gop.found_existing) { + // If this is a new entry we can just remove it since it + // was never sent to the GPU. + _ = self.images.remove(id); + } else { + // If this was an existing entry, it is invalid and + // we must unload it. + gop.value_ptr.image.markForUnload(); + } + + return error.OutOfMemory; + }; + // Note: we don't need to errdefer free the data because it is + // put into the map immediately below and our errdefer to + // handle our map state will fix this up. + + // Store it in the map + const new_image: Image = .{ + .pending = .{ + .width = pending.width, + .height = pending.height, + .pixel_format = pending.pixel_format, + .data = data.ptr, + }, + }; + if (!gop.found_existing) { + gop.value_ptr.* = .{ + .image = new_image, + .transmit_time = undefined, + }; + } else { + gop.value_ptr.image.markForReplace( + alloc, + new_image, + ); + } + + // If any error happens, we unload the image and it is invalid. + errdefer gop.value_ptr.image.markForUnload(); + + gop.value_ptr.image.prepForUpload(alloc) catch |err| { + log.warn("error preparing image for upload err={}", .{err}); + return error.ImageConversionError; + }; + gop.value_ptr.transmit_time = transmit_time; + } + + /// Prepare the provided Kitty image for upload to the GPU by copying its + /// data with our allocator and setting it to the pending state. + fn prepKittyImage( + self: *State, + alloc: Allocator, + image: *const terminal.kitty.graphics.Image, + ) PrepImageError!void { + try self.prepImage( + alloc, + .{ .kitty = image.id }, + image.transmit_time, + .{ + .width = image.width, + .height = image.height, + .pixel_format = switch (image.format) { + .gray => .gray, + .gray_alpha => .gray_alpha, + .rgb => .rgb, + .rgba => .rgba, + .png => unreachable, // should be decoded by now + }, + + // constCasts are always gross but this one is safe is because + // the data is only read from here and copied into its own + // buffer. + .data = @constCast(image.data.ptr), + }, + ); + } +}; + +/// Represents a single image placement on the grid. +/// A placement is a request to render an instance of an image. +pub const Placement = struct { + /// The image being rendered. This MUST be in the image map. + image_id: Id, + + /// The grid x/y where this placement is located. + x: i32, + y: i32, + z: i32, + + /// The width/height of the placed image. + width: u32, + height: u32, + + /// The offset in pixels from the top left of the cell. + /// This is clamped to the size of a cell. + cell_offset_x: u32, + cell_offset_y: u32, + + /// The source rectangle of the placement. + source_x: u32, + source_y: u32, + source_width: u32, + source_height: u32, +}; + +/// Image identifier used to store and lookup images. +/// +/// This is tagged by different image types to make it easier to +/// store different kinds of images in the same map without having +/// to worry about ID collisions. +pub const Id = union(enum) { + /// Image sent to the terminal state via the kitty graphics protocol. + /// The value is the ID assigned by the terminal. + kitty: u32, + + /// Debug overlay. This is always composited down to a single + /// image for now. In the future we can support layers here if we want. + overlay, + + /// Z-ordering tie-breaker for images with the same z value. + pub fn zLessThan(lhs: Id, rhs: Id) bool { + // If our tags aren't the same, we sort by tag. + if (std.meta.activeTag(lhs) != std.meta.activeTag(rhs)) { + return switch (lhs) { + // Kitty images always sort before (lower z) non-kitty images. + .kitty => true, + + .overlay => false, + }; + } + + switch (lhs) { + .kitty => |lhs_id| { + const rhs_id = rhs.kitty; + return lhs_id < rhs_id; + }, + + // No sensical ordering + .overlay => return false, + } + } +}; + +/// The map used for storing images. +pub const ImageMap = std.AutoHashMapUnmanaged(Id, struct { + image: Image, + transmit_time: std.time.Instant, +}); + +/// The state for a single image that is to be rendered. +pub const Image = union(enum) { + /// The image data is pending upload to the GPU. + /// + /// This data is owned by this union so it must be freed once uploaded. + pending: Pending, + + /// This is the same as the pending states but there is + /// a texture already allocated that we want to replace. + replace: Replace, + + /// The image is uploaded and ready to be used. + ready: Texture, + + /// The image isn't uploaded yet but is scheduled to be unloaded. + unload_pending: Pending, + /// The image is uploaded and is scheduled to be unloaded. + unload_ready: Texture, + /// The image is uploaded and scheduled to be replaced + /// with new data, but it's also scheduled to be unloaded. + unload_replace: Replace, + + pub const Replace = struct { + texture: Texture, + pending: Pending, + }; + + /// Pending image data that needs to be uploaded to the GPU. + pub const Pending = struct { + height: u32, + width: u32, + pixel_format: PixelFormat, + + /// Data is always expected to be (width * height * bpp). + data: [*]u8, + + pub fn dataSlice(self: Pending) []u8 { + return self.data[0..self.len()]; + } + + pub fn len(self: Pending) usize { + return self.width * self.height * self.pixel_format.bpp(); + } + + pub const PixelFormat = enum { + /// 1 byte per pixel grayscale. + gray, + /// 2 bytes per pixel grayscale + alpha. + gray_alpha, + /// 3 bytes per pixel RGB. + rgb, + /// 3 bytes per pixel BGR. + bgr, + /// 4 byte per pixel RGBA. + rgba, + /// 4 byte per pixel BGRA. + bgra, + + /// Get bytes per pixel for this format. + pub inline fn bpp(self: PixelFormat) usize { + return switch (self) { + .gray => 1, + .gray_alpha => 2, + .rgb => 3, + .bgr => 3, + .rgba => 4, + .bgra => 4, + }; + } + }; + }; + + pub fn deinit(self: Image, alloc: Allocator) void { + switch (self) { + .pending, + .unload_pending, + => |p| alloc.free(p.dataSlice()), + + .replace, .unload_replace => |r| { + alloc.free(r.pending.dataSlice()); + r.texture.deinit(); + }, + + .ready, + .unload_ready, + => |t| t.deinit(), + } + } + + /// Mark this image for unload whatever state it is in. + pub fn markForUnload(self: *Image) void { + self.* = switch (self.*) { + .unload_pending, + .unload_replace, + .unload_ready, + => return, + + .ready => |t| .{ .unload_ready = t }, + .pending => |p| .{ .unload_pending = p }, + .replace => |r| .{ .unload_replace = r }, + }; + } + + /// Mark the current image to be replaced with a pending one. This will + /// attempt to update the existing texture if we have one, otherwise it + /// will act like a new upload. + pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) void { + assert(img.isPending()); + + // If we have pending data right now, free it. + if (self.getPending()) |p| { + alloc.free(p.dataSlice()); + } + // If we have an existing texture, use it in the replace. + if (self.getTexture()) |t| { + self.* = .{ .replace = .{ + .texture = t, + .pending = img.getPending().?, + } }; + return; + } + // Otherwise we just become a pending image. + self.* = .{ .pending = img.getPending().? }; + } + + /// Returns true if this image is pending upload. + pub fn isPending(self: Image) bool { + return self.getPending() != null; + } + + /// Returns true if this image has an associated texture. + pub fn hasTexture(self: Image) bool { + return self.getTexture() != null; + } + + /// Returns true if this image is marked for unload. + pub fn isUnloading(self: Image) bool { + return switch (self) { + .unload_pending, + .unload_replace, + .unload_ready, + => true, + + .pending, + .replace, + .ready, + => false, + }; + } + + /// Converts the image data to a format that can be uploaded to the GPU. + /// If the data is already in a format that can be uploaded, this is a + /// no-op. + fn convert(self: *Image, alloc: Allocator) wuffs.Error!void { + const p = self.getPendingPointer().?; + // As things stand, we currently convert all images to RGBA before + // uploading to the GPU. This just makes things easier. In the future + // we may want to support other formats. + if (p.pixel_format == .rgba) return; + // If the pending data isn't RGBA we'll need to swizzle it. + const data = p.dataSlice(); + const rgba = try switch (p.pixel_format) { + .gray => wuffs.swizzle.gToRgba(alloc, data), + .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data), + .rgb => wuffs.swizzle.rgbToRgba(alloc, data), + .bgr => wuffs.swizzle.bgrToRgba(alloc, data), + .rgba => unreachable, + .bgra => wuffs.swizzle.bgraToRgba(alloc, data), + }; + alloc.free(data); + p.data = rgba.ptr; + p.pixel_format = .rgba; + } + + /// Prepare the pending image data for upload to the GPU. + /// This doesn't need GPU access so is safe to call any time. + fn prepForUpload(self: *Image, alloc: Allocator) wuffs.Error!void { + assert(self.isPending()); + try self.convert(alloc); + } + + /// Upload the pending image to the GPU and change the state of this + /// image to ready. + pub fn upload( + self: *Image, + alloc: Allocator, + api: *const GraphicsAPI, + ) (wuffs.Error || error{ + /// Texture creation failed, usually a GPU memory issue. + UploadFailed, + })!void { + assert(self.isPending()); + + // No error recover is required after this call because it just + // converts in place and is idempotent. + try self.prepForUpload(alloc); + + // Get our pending info + const p = self.getPending().?; + + // Create our texture + const texture = Texture.init( + api.imageTextureOptions(.rgba, true), + @intCast(p.width), + @intCast(p.height), + p.dataSlice(), + ) catch return error.UploadFailed; + errdefer comptime unreachable; + + // Uploaded. We can now clear our data and change our state. + // + // NOTE: For the `replace` state, this will free the old texture. + // We don't currently actually replace the existing texture + // in-place but that is an optimization we can do later. + self.deinit(alloc); + self.* = .{ .ready = texture }; + } + + /// Returns any pending image data for this image that requires upload. + /// + /// If there is no pending data to upload, returns null. + fn getPending(self: Image) ?Pending { + return switch (self) { + .pending, + .unload_pending, + => |p| p, + + .replace, + .unload_replace, + => |r| r.pending, + + else => null, + }; + } + + /// Returns the texture for this image. + /// + /// If there is no texture for it yet, returns null. + fn getTexture(self: Image) ?Texture { + return switch (self) { + .ready, + .unload_ready, + => |t| t, + + .replace, + .unload_replace, + => |r| r.texture, + + else => null, + }; + } + + // Same as getPending but returns a pointer instead of a copy. + fn getPendingPointer(self: *Image) ?*Pending { + return switch (self.*) { + .pending => return &self.pending, + .unload_pending => return &self.unload_pending, + + .replace => return &self.replace.pending, + .unload_replace => return &self.unload_replace.pending, + + else => null, + }; + } +}; diff --git a/vendor/libghostty-vt/src/renderer/link.zig b/vendor/libghostty-vt/src/renderer/link.zig new file mode 100644 index 00000000..c5de6157 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/link.zig @@ -0,0 +1,322 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const oni = @import("oniguruma"); +const inputpkg = @import("../input.zig"); +const terminal = @import("../terminal/main.zig"); +const point = terminal.point; +const Screen = terminal.Screen; +const Terminal = terminal.Terminal; + +const log = std.log.scoped(.renderer_link); + +/// The link configuration needed for renderers. +pub const Link = struct { + /// The regular expression to match the link against. + regex: oni.Regex, + + /// The situations in which the link should be highlighted. + highlight: inputpkg.Link.Highlight, + + pub fn deinit(self: *Link) void { + self.regex.deinit(); + } +}; + +/// A set of links. This provides a higher level API for renderers +/// to match against a viewport and determine if cells are part of +/// a link. +pub const Set = struct { + links: []Link, + + /// Returns the slice of links from the configuration. + pub fn fromConfig( + alloc: Allocator, + config: []const inputpkg.Link, + ) !Set { + var links: std.ArrayList(Link) = .empty; + defer links.deinit(alloc); + + for (config) |link| { + var regex = try link.oniRegex(); + errdefer regex.deinit(); + try links.append(alloc, .{ + .regex = regex, + .highlight = link.highlight, + }); + } + + return .{ .links = try links.toOwnedSlice(alloc) }; + } + + pub fn deinit(self: *Set, alloc: Allocator) void { + for (self.links) |*link| link.deinit(); + alloc.free(self.links); + } + + /// Fills matches with the matches from regex link matches. + pub fn renderCellMap( + self: *const Set, + alloc: Allocator, + result: *terminal.RenderState.CellSet, + render_state: *const terminal.RenderState, + mouse_viewport: ?point.Coordinate, + mouse_mods: inputpkg.Mods, + ) !void { + // Fast path, not very likely since we have default links. + if (self.links.len == 0) return; + + // Convert our render state to a string + byte map. + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + var map: terminal.RenderState.StringMap = .empty; + defer map.deinit(alloc); + try render_state.string(&builder.writer, .{ + .alloc = alloc, + .map = &map, + }); + + const str = builder.writer.buffered(); + + // Go through each link and see if we have any matches. + for (self.links) |*link| { + // Determine if our highlight conditions are met. We use a + // switch here instead of an if so that we can get a compile + // error if any other conditions are added. + switch (link.highlight) { + .always => {}, + .always_mods => |v| if (!mouse_mods.equal(v)) continue, + + // We check the hover points later. + .hover => if (mouse_viewport == null) continue, + .hover_mods => |v| { + if (mouse_viewport == null) continue; + if (!mouse_mods.equal(v)) continue; + }, + } + + var offset: usize = 0; + while (offset < str.len) { + var region = link.regex.search( + str[offset..], + .{}, + ) catch |err| switch (err) { + error.Mismatch => break, + else => return err, + }; + defer region.deinit(); + + // We have a match! + const offset_start: usize = @intCast(region.starts()[0]); + const offset_end: usize = @intCast(region.ends()[0]); + const start = offset + offset_start; + const end = offset + offset_end; + + // Increment our offset by the number of bytes in the match. + // We defer this so that we can return the match before + // modifying the offset. + defer offset = end; + + switch (link.highlight) { + .always, .always_mods => {}, + .hover, .hover_mods => if (mouse_viewport) |vp| { + for (map.items[start..end]) |pt| { + if (pt.eql(vp)) break; + } else continue; + } else continue, + } + + // Record the match + for (map.items[start..end]) |pt| { + try result.put(alloc, pt, {}); + } + } + } + } +}; + +test "renderCellMap" { + const testing = std.testing; + const alloc = testing.allocator; + + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + const str = "1ABCD2EFGH\r\n3IJKL"; + s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get a set + var set = try Set.fromConfig(alloc, &.{ + .{ + .regex = "AB", + .action = .{ .open = {} }, + .highlight = .{ .always = {} }, + }, + + .{ + .regex = "EF", + .action = .{ .open = {} }, + .highlight = .{ .always = {} }, + }, + }); + defer set.deinit(alloc); + + // Get our matches + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + null, + .{}, + ); + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); +} + +test "renderCellMap hover links" { + const testing = std.testing; + const alloc = testing.allocator; + + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + const str = "1ABCD2EFGH\r\n3IJKL"; + s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get a set + var set = try Set.fromConfig(alloc, &.{ + .{ + .regex = "AB", + .action = .{ .open = {} }, + .highlight = .{ .hover = {} }, + }, + + .{ + .regex = "EF", + .action = .{ .open = {} }, + .highlight = .{ .always = {} }, + }, + }); + defer set.deinit(alloc); + + // Not hovering over the first link + { + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + null, + .{}, + ); + + // Test our matches + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); + } + + // Hovering over the first link + { + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + .{ .x = 1, .y = 0 }, + .{}, + ); + + // Test our matches + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); + } +} + +test "renderCellMap mods no match" { + const testing = std.testing; + const alloc = testing.allocator; + + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + const str = "1ABCD2EFGH\r\n3IJKL"; + s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // Get a set + var set = try Set.fromConfig(alloc, &.{ + .{ + .regex = "AB", + .action = .{ .open = {} }, + .highlight = .{ .always = {} }, + }, + + .{ + .regex = "EF", + .action = .{ .open = {} }, + .highlight = .{ .always_mods = .{ .ctrl = true } }, + }, + }); + defer set.deinit(alloc); + + // Get our matches + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(alloc); + try set.renderCellMap( + alloc, + &result, + &state, + null, + .{}, + ); + + // Test our matches + try testing.expect(!result.contains(.{ .x = 0, .y = 0 })); + try testing.expect(result.contains(.{ .x = 1, .y = 0 })); + try testing.expect(result.contains(.{ .x = 2, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 3, .y = 0 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 1 })); + try testing.expect(!result.contains(.{ .x = 1, .y = 2 })); +} diff --git a/vendor/libghostty-vt/src/renderer/message.zig b/vendor/libghostty-vt/src/renderer/message.zig new file mode 100644 index 00000000..a47b9608 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/message.zig @@ -0,0 +1,111 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const configpkg = @import("../config.zig"); +const font = @import("../font/main.zig"); +const renderer = @import("../renderer.zig"); +const terminal = @import("../terminal/main.zig"); + +/// The messages that can be sent to a renderer thread. +pub const Message = union(enum) { + /// Purposely crash the renderer. This is used for testing and debugging. + /// See the "crash" binding action. + crash, + + /// A change in state in the window focus that this renderer is + /// rendering within. This is only sent when a change is detected so + /// the renderer is expected to handle all of these. + focus: bool, + + /// A change in the view occlusion state. This can be used to determine + /// if the window is visible or not. A window can be not visible (occluded) + /// and still have focus. + visible: bool, + + /// Reset the cursor blink by immediately showing the cursor then + /// restarting the timer. + reset_cursor_blink, + + /// Change the font grid. This can happen for any number of reasons + /// including a font size change, family change, etc. + font_grid: struct { + grid: *font.SharedGrid, + set: *font.SharedGridSet, + + // The key for the new grid. If adopting the new grid fails for any + // reason, the old grid should be kept but the new key should be + // dereferenced. + new_key: font.SharedGridSet.Key, + + // After accepting the new grid, the old grid must be dereferenced + // using the fields below. + old_key: font.SharedGridSet.Key, + }, + + /// Changes the size. The screen size might change, padding, grid, etc. + resize: renderer.Size, + + /// The derived configuration to update the renderer with. + change_config: struct { + alloc: Allocator, + thread: *renderer.Thread.DerivedConfig, + impl: *renderer.Renderer.DerivedConfig, + }, + + /// Matches for the current viewport from the search thread. These happen + /// async so they may be off for a frame or two from the actually rendered + /// viewport. The renderer must handle this gracefully. + search_viewport_matches: SearchMatches, + + /// The selected match from the search thread. May be null to indicate + /// no match currently. + search_selected_match: ?SearchMatch, + + /// Activate or deactivate the inspector. + inspector: bool, + + /// The macOS display ID has changed for the window. + macos_display_id: u32, + + pub const SearchMatches = struct { + arena: ArenaAllocator, + matches: []const terminal.highlight.Flattened, + }; + + pub const SearchMatch = struct { + arena: ArenaAllocator, + match: terminal.highlight.Flattened, + }; + + /// Initialize a change_config message. + pub fn initChangeConfig(alloc: Allocator, config: *const configpkg.Config) !Message { + const thread_ptr = try alloc.create(renderer.Thread.DerivedConfig); + errdefer alloc.destroy(thread_ptr); + const config_ptr = try alloc.create(renderer.Renderer.DerivedConfig); + errdefer alloc.destroy(config_ptr); + + thread_ptr.* = renderer.Thread.DerivedConfig.init(config); + config_ptr.* = try renderer.Renderer.DerivedConfig.init(alloc, config); + errdefer config_ptr.deinit(); + + return .{ + .change_config = .{ + .alloc = alloc, + .thread = thread_ptr, + .impl = config_ptr, + }, + }; + } + + pub fn deinit(self: *const Message) void { + switch (self.*) { + .change_config => |v| { + v.impl.deinit(); + v.alloc.destroy(v.impl); + v.alloc.destroy(v.thread); + }, + + else => {}, + } + } +}; diff --git a/vendor/libghostty-vt/src/renderer/metal/Frame.zig b/vendor/libghostty-vt/src/renderer/metal/Frame.zig new file mode 100644 index 00000000..388b4f9e --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/metal/Frame.zig @@ -0,0 +1,131 @@ +//! Wrapper for handling render passes. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const objc = @import("objc"); + +const mtl = @import("api.zig"); +const Renderer = @import("../generic.zig").Renderer(Metal); +const Metal = @import("../Metal.zig"); +const Target = @import("Target.zig"); +const RenderPass = @import("RenderPass.zig"); + +const Health = @import("../../renderer.zig").Health; + +const log = std.log.scoped(.metal); + +/// Options for beginning a frame. +pub const Options = struct { + /// MTLCommandQueue + queue: objc.Object, +}; + +/// MTLCommandBuffer +buffer: objc.Object, + +block: CompletionBlock.Context, + +/// Begin encoding a frame. +pub fn begin( + opts: Options, + /// Once the frame has been completed, the `frameCompleted` method + /// on the renderer is called with the health status of the frame. + renderer: *Renderer, + /// The target is presented via the provided renderer's API when completed. + target: *Target, +) !Self { + const buffer = opts.queue.msgSend( + objc.Object, + objc.sel("commandBuffer"), + .{}, + ); + + // Create our block to register for completion updates. + // The block is deallocated by the objC runtime on success. + const block = CompletionBlock.init( + .{ + .renderer = renderer, + .target = target, + .sync = false, + }, + &bufferCompleted, + ); + + return .{ .buffer = buffer, .block = block }; +} + +/// This is the block type used for the addCompletedHandler callback. +const CompletionBlock = objc.Block(struct { + renderer: *Renderer, + target: *Target, + sync: bool, +}, .{ + objc.c.id, // MTLCommandBuffer +}, void); + +fn bufferCompleted( + block: *const CompletionBlock.Context, + buffer_id: objc.c.id, +) callconv(.c) void { + const buffer = objc.Object.fromId(buffer_id); + + // Get our command buffer status to pass back to the generic renderer. + const status = buffer.getProperty(mtl.MTLCommandBufferStatus, "status"); + const health: Health = switch (status) { + .@"error" => .unhealthy, + else => .healthy, + }; + + // If the frame is healthy, present it. + if (health == .healthy) { + block.renderer.api.present( + block.target.*, + block.sync, + ) catch |err| { + log.err("Failed to present render target: err={}", .{err}); + }; + } + + block.renderer.frameCompleted(health); +} + +/// Add a render pass to this frame with the provided attachments. +/// Returns a RenderPass which allows render steps to be added. +pub inline fn renderPass( + self: *const Self, + attachments: []const RenderPass.Options.Attachment, +) RenderPass { + return RenderPass.begin(.{ + .attachments = attachments, + .command_buffer = self.buffer, + }); +} + +/// Complete this frame and present the target. +/// +/// If `sync` is true, this will block until the frame is presented. +pub inline fn complete(self: *Self, sync: bool) void { + // If we don't need to complete synchronously, + // we add our block as a completion handler. + // + // It will be copied when we add the handler, and then the + // copy will be deallocated by the objc runtime on success. + if (!sync) { + self.buffer.msgSend( + void, + objc.sel("addCompletedHandler:"), + .{&self.block}, + ); + } + + self.buffer.msgSend(void, objc.sel("commit"), .{}); + + // If we need to complete synchronously, we wait until + // the buffer is completed and invoke the block directly. + if (sync) { + self.buffer.msgSend(void, "waitUntilCompleted", .{}); + self.block.sync = true; + CompletionBlock.invoke(&self.block, .{self.buffer.value}); + } +} diff --git a/vendor/libghostty-vt/src/renderer/metal/IOSurfaceLayer.zig b/vendor/libghostty-vt/src/renderer/metal/IOSurfaceLayer.zig new file mode 100644 index 00000000..34fbfbed --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/metal/IOSurfaceLayer.zig @@ -0,0 +1,183 @@ +//! A wrapper around a CALayer with a utility method +//! for settings its `contents` to an IOSurface. +const IOSurfaceLayer = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const objc = @import("objc"); +const macos = @import("macos"); + +const IOSurface = macos.iosurface.IOSurface; + +const log = std.log.scoped(.IOSurfaceLayer); + +/// We subclass CALayer with a custom display handler, we only need +/// to make the subclass once, and then we can use it as a singleton. +var Subclass: ?objc.Class = null; + +/// The underlying CALayer +layer: objc.Object, + +pub fn init() !IOSurfaceLayer { + // The layer returned by `[CALayer layer]` is autoreleased, which means + // that at the end of the current autorelease pool it will be deallocated + // if it isn't retained, so we retain it here manually an extra time. + const layer = (try getSubclass()).msgSend( + objc.Object, + objc.sel("layer"), + .{}, + ).retain(); + errdefer layer.release(); + + // The layer gravity is set to top-left so that the contents aren't + // stretched during resize operations before a new frame has been drawn. + layer.setProperty("contentsGravity", macos.animation.kCAGravityTopLeft); + + layer.setInstanceVariable("display_cb", .{ .value = null }); + layer.setInstanceVariable("display_ctx", .{ .value = null }); + + return .{ .layer = layer }; +} + +pub fn release(self: *IOSurfaceLayer) void { + self.layer.release(); +} + +/// Sets the layer's `contents` to the provided IOSurface. +/// +/// Makes sure to do so on the main thread to avoid visual artifacts. +pub inline fn setSurface(self: *IOSurfaceLayer, surface: *IOSurface) !void { + // We retain the surface to make sure it's not GC'd + // before we can set it as the contents of the layer. + // + // We release in the callback after setting the contents. + surface.retain(); + // NOTE: Since `self.layer` is passed as an `objc.c.id`, it's + // automatically retained when the block is copied, so we + // don't need to retain it ourselves like with the surface. + + var block = SetSurfaceBlock.init(.{ + .layer = self.layer.value, + .surface = surface, + }, &setSurfaceCallback); + + // We check if we're on the main thread and run the block directly if so. + const NSThread = objc.getClass("NSThread").?; + if (NSThread.msgSend(bool, "isMainThread", .{})) { + setSurfaceCallback(&block); + } else { + // NOTE: The block will be copied when we pass it to dispatch_async, + // and then automatically be deallocated by the objc runtime + // once it's executed. + + macos.dispatch.dispatch_async( + @ptrCast(macos.dispatch.queue.getMain()), + @ptrCast(&block), + ); + } +} + +/// Sets the layer's `contents` to the provided IOSurface. +/// +/// Does not ensure this happens on the main thread. +pub inline fn setSurfaceSync(self: *IOSurfaceLayer, surface: *IOSurface) void { + self.layer.setProperty("contents", surface); +} + +const SetSurfaceBlock = objc.Block(struct { + layer: objc.c.id, + surface: *IOSurface, +}, .{}, void); + +fn setSurfaceCallback( + block: *const SetSurfaceBlock.Context, +) callconv(.c) void { + const layer = objc.Object.fromId(block.layer); + const surface: *IOSurface = block.surface; + + // See explanation of why we retain and release in `setSurface`. + defer surface.release(); + + // We check to see if the surface is the appropriate size for + // the layer, if it's not then we discard it. This is because + // asynchronously drawn frames can sometimes finish just after + // a synchronously drawn frame during a resize, and if we don't + // discard the improperly sized surface it creates jank. + const bounds = layer.getProperty(macos.graphics.Rect, "bounds"); + const scale = layer.getProperty(f64, "contentsScale"); + const width: usize = @intFromFloat(bounds.size.width * scale); + const height: usize = @intFromFloat(bounds.size.height * scale); + if (width != surface.getWidth() or height != surface.getHeight()) { + log.debug( + "setSurfaceCallback(): surface is wrong size for layer, discarding. surface = {d}x{d}, layer = {d}x{d}", + .{ surface.getWidth(), surface.getHeight(), width, height }, + ); + return; + } + + layer.setProperty("contents", surface); +} + +pub const DisplayCallback = ?*align(8) const fn (?*anyopaque) void; + +pub fn setDisplayCallback( + self: *IOSurfaceLayer, + display_cb: DisplayCallback, + display_ctx: ?*anyopaque, +) void { + self.layer.setInstanceVariable( + "display_cb", + objc.Object.fromId(@constCast(display_cb)), + ); + self.layer.setInstanceVariable( + "display_ctx", + objc.Object.fromId(display_ctx), + ); +} + +fn getSubclass() error{ObjCFailed}!objc.Class { + if (Subclass) |c| return c; + + const CALayer = + objc.getClass("CALayer") orelse return error.ObjCFailed; + + var subclass = + objc.allocateClassPair(CALayer, "IOSurfaceLayer") orelse return error.ObjCFailed; + errdefer objc.disposeClassPair(subclass); + + if (!subclass.addIvar("display_cb")) return error.ObjCFailed; + if (!subclass.addIvar("display_ctx")) return error.ObjCFailed; + + subclass.replaceMethod("display", struct { + fn display(target: objc.c.id, sel: objc.c.SEL) callconv(.c) void { + _ = sel; + const self = objc.Object.fromId(target); + const display_cb: DisplayCallback = @ptrFromInt(@intFromPtr( + self.getInstanceVariable("display_cb").value, + )); + if (display_cb) |cb| cb( + @ptrCast(self.getInstanceVariable("display_ctx").value), + ); + } + }.display); + + // Disable all animations for this layer by returning null for all actions. + subclass.replaceMethod("actionForKey:", struct { + fn actionForKey( + target: objc.c.id, + sel: objc.c.SEL, + key: objc.c.id, + ) callconv(.c) objc.c.id { + _ = target; + _ = sel; + _ = key; + return objc.getClass("NSNull").?.msgSend(objc.c.id, "null", .{}); + } + }.actionForKey); + + objc.registerClassPair(subclass); + + Subclass = subclass; + + return subclass; +} diff --git a/vendor/libghostty-vt/src/renderer/metal/Pipeline.zig b/vendor/libghostty-vt/src/renderer/metal/Pipeline.zig new file mode 100644 index 00000000..9ba25c35 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/metal/Pipeline.zig @@ -0,0 +1,204 @@ +//! Wrapper for handling render pipelines. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const macos = @import("macos"); +const objc = @import("objc"); + +const mtl = @import("api.zig"); + +const log = std.log.scoped(.metal); + +/// Options for initializing a render pipeline. +pub const Options = struct { + /// MTLDevice + device: objc.Object, + + /// Name of the vertex function + vertex_fn: []const u8, + /// Name of the fragment function + fragment_fn: []const u8, + + /// MTLLibrary to get the vertex function from + vertex_library: objc.Object, + /// MTLLibrary to get the fragment function from + fragment_library: objc.Object, + + /// Vertex step function + step_fn: mtl.MTLVertexStepFunction = .per_vertex, + + /// Info about the color attachments used by this render pipeline. + attachments: []const Attachment, + + /// Describes a color attachment. + pub const Attachment = struct { + pixel_format: mtl.MTLPixelFormat, + blending_enabled: bool = true, + }; +}; + +/// MTLRenderPipelineState +state: objc.Object, + +pub fn init(comptime VertexAttributes: ?type, opts: Options) !Self { + // Create our descriptor + const desc = init: { + const Class = objc.getClass("MTLRenderPipelineDescriptor").?; + const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); + const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); + break :init id_init; + }; + defer desc.msgSend(void, objc.sel("release"), .{}); + + // Get our vertex and fragment functions and add them to the descriptor. + { + const str = try macos.foundation.String.createWithBytes( + opts.vertex_fn, + .utf8, + false, + ); + defer str.release(); + + const ptr = opts.vertex_library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); + const func_vert = objc.Object.fromId(ptr.?); + defer func_vert.msgSend(void, objc.sel("release"), .{}); + + desc.setProperty("vertexFunction", func_vert); + } + { + const str = try macos.foundation.String.createWithBytes( + opts.fragment_fn, + .utf8, + false, + ); + defer str.release(); + + const ptr = opts.fragment_library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); + const func_frag = objc.Object.fromId(ptr.?); + defer func_frag.msgSend(void, objc.sel("release"), .{}); + + desc.setProperty("fragmentFunction", func_frag); + } + + // If we have vertex attributes, create and add a vertex descriptor. + if (VertexAttributes) |V| { + const vertex_desc = init: { + const Class = objc.getClass("MTLVertexDescriptor").?; + const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); + const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); + break :init id_init; + }; + defer vertex_desc.msgSend(void, objc.sel("release"), .{}); + + // Our attributes are the fields of the input + const attrs = objc.Object.fromId(vertex_desc.getProperty(?*anyopaque, "attributes")); + autoAttribute(V, attrs); + + // The layout describes how and when we fetch the next vertex input. + const layouts = objc.Object.fromId(vertex_desc.getProperty(?*anyopaque, "layouts")); + { + const layout = layouts.msgSend( + objc.Object, + objc.sel("objectAtIndexedSubscript:"), + .{@as(c_ulong, 0)}, + ); + + layout.setProperty("stepFunction", @intFromEnum(opts.step_fn)); + layout.setProperty("stride", @as(c_ulong, @sizeOf(V))); + } + + desc.setProperty("vertexDescriptor", vertex_desc); + } + + // Set our color attachment + const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments")); + for (opts.attachments, 0..) |at, i| { + const attachment = attachments.msgSend( + objc.Object, + objc.sel("objectAtIndexedSubscript:"), + .{@as(c_ulong, i)}, + ); + + attachment.setProperty("pixelFormat", @intFromEnum(at.pixel_format)); + + attachment.setProperty("blendingEnabled", at.blending_enabled); + // We always use premultiplied alpha blending for now. + if (at.blending_enabled) { + attachment.setProperty("rgbBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add)); + attachment.setProperty("alphaBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add)); + attachment.setProperty("sourceRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one)); + attachment.setProperty("sourceAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one)); + attachment.setProperty("destinationRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha)); + attachment.setProperty("destinationAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha)); + } + } + + // Make our state + var err: ?*anyopaque = null; + const pipeline_state = opts.device.msgSend( + objc.Object, + objc.sel("newRenderPipelineStateWithDescriptor:error:"), + .{ desc, &err }, + ); + try checkError(err); + errdefer pipeline_state.release(); + + return .{ .state = pipeline_state }; +} + +pub fn deinit(self: *const Self) void { + self.state.release(); +} + +fn autoAttribute(T: type, attrs: objc.Object) void { + inline for (@typeInfo(T).@"struct".fields, 0..) |field, i| { + const offset = @offsetOf(T, field.name); + + const FT = switch (@typeInfo(field.type)) { + .@"struct" => |e| e.backing_integer.?, + .@"enum" => |e| e.tag_type, + else => field.type, + }; + + // Very incomplete list, expand as necessary. + const format = switch (FT) { + [4]u8 => mtl.MTLVertexFormat.uchar4, + [2]u16 => mtl.MTLVertexFormat.ushort2, + [2]i16 => mtl.MTLVertexFormat.short2, + f32 => mtl.MTLVertexFormat.float, + [2]f32 => mtl.MTLVertexFormat.float2, + [4]f32 => mtl.MTLVertexFormat.float4, + i32 => mtl.MTLVertexFormat.int, + [2]i32 => mtl.MTLVertexFormat.int2, + [4]i32 => mtl.MTLVertexFormat.int2, + u32 => mtl.MTLVertexFormat.uint, + [2]u32 => mtl.MTLVertexFormat.uint2, + [4]u32 => mtl.MTLVertexFormat.uint4, + u8 => mtl.MTLVertexFormat.uchar, + i8 => mtl.MTLVertexFormat.char, + else => comptime unreachable, + }; + + const attr = attrs.msgSend( + objc.Object, + objc.sel("objectAtIndexedSubscript:"), + .{@as(c_ulong, i)}, + ); + + attr.setProperty("format", @intFromEnum(format)); + attr.setProperty("offset", @as(c_ulong, offset)); + attr.setProperty("bufferIndex", @as(c_ulong, 0)); + } +} + +fn checkError(err_: ?*anyopaque) !void { + const nserr = objc.Object.fromId(err_ orelse return); + const str = @as( + *macos.foundation.String, + @ptrCast(nserr.getProperty(?*anyopaque, "localizedDescription").?), + ); + + log.err("metal error={s}", .{str.cstringPtr(.ascii).?}); + return error.MetalFailed; +} diff --git a/vendor/libghostty-vt/src/renderer/metal/RenderPass.zig b/vendor/libghostty-vt/src/renderer/metal/RenderPass.zig new file mode 100644 index 00000000..f204e177 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/metal/RenderPass.zig @@ -0,0 +1,229 @@ +//! Wrapper for handling render passes. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const objc = @import("objc"); + +const mtl = @import("api.zig"); +const Pipeline = @import("Pipeline.zig"); +const Sampler = @import("Sampler.zig"); +const Texture = @import("Texture.zig"); +const Target = @import("Target.zig"); + +const log = std.log.scoped(.metal); + +/// Options for beginning a render pass. +pub const Options = struct { + /// MTLCommandBuffer + command_buffer: objc.Object, + /// Color attachments for this render pass. + attachments: []const Attachment, + + /// Describes a color attachment. + pub const Attachment = struct { + target: union(enum) { + texture: Texture, + target: Target, + }, + clear_color: ?[4]f64 = null, + }; +}; + +/// Describes a step in a render pass. +pub const Step = struct { + pipeline: Pipeline, + /// MTLBuffer + uniforms: ?objc.Object = null, + /// MTLBuffer + buffers: []const ?objc.Object = &.{}, + textures: []const ?Texture = &.{}, + /// Set of samplers to use for this step. The index maps to an index + /// of a fragment texture, set via setFragmentSamplerState(_:index:). + samplers: []const ?Sampler = &.{}, + draw: Draw, + + /// Describes the draw call for this step. + pub const Draw = struct { + type: mtl.MTLPrimitiveType, + vertex_count: usize, + instance_count: usize = 1, + }; +}; + +/// MTLRenderCommandEncoder +encoder: objc.Object, + +/// Begin a render pass. +pub fn begin( + opts: Options, +) Self { + // Create a pass descriptor + const desc = desc: { + const MTLRenderPassDescriptor = objc.getClass("MTLRenderPassDescriptor").?; + const desc = MTLRenderPassDescriptor.msgSend( + objc.Object, + objc.sel("renderPassDescriptor"), + .{}, + ); + + // Set our color attachment to be our drawable surface. + const attachments = objc.Object.fromId( + desc.getProperty(?*anyopaque, "colorAttachments"), + ); + for (opts.attachments, 0..) |at, i| { + const attachment = attachments.msgSend( + objc.Object, + objc.sel("objectAtIndexedSubscript:"), + .{@as(c_ulong, i)}, + ); + + attachment.setProperty( + "loadAction", + @intFromEnum(@as( + mtl.MTLLoadAction, + if (at.clear_color != null) + .clear + else + .load, + )), + ); + attachment.setProperty( + "storeAction", + @intFromEnum(mtl.MTLStoreAction.store), + ); + attachment.setProperty("texture", switch (at.target) { + .texture => |t| t.texture.value, + .target => |t| t.texture.value, + }); + if (at.clear_color) |c| attachment.setProperty( + "clearColor", + mtl.MTLClearColor{ + .red = c[0], + .green = c[1], + .blue = c[2], + .alpha = c[3], + }, + ); + } + + break :desc desc; + }; + + // MTLRenderCommandEncoder + const encoder = opts.command_buffer.msgSend( + objc.Object, + objc.sel("renderCommandEncoderWithDescriptor:"), + .{desc.value}, + ); + + return .{ .encoder = encoder }; +} + +/// Add a step to this render pass. +pub fn step(self: *const Self, s: Step) void { + if (s.draw.instance_count == 0) return; + + // Set pipeline state + self.encoder.msgSend( + void, + objc.sel("setRenderPipelineState:"), + .{s.pipeline.state.value}, + ); + + if (s.buffers.len > 0) { + // We reserve index 0 for the vertex buffer, this isn't very + // flexible but it lines up with the API we have for OpenGL. + if (s.buffers[0]) |buf| { + self.encoder.msgSend( + void, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 0) }, + ); + self.encoder.msgSend( + void, + objc.sel("setFragmentBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 0) }, + ); + } + + // Set the rest of the buffers starting at index 2, this is + // so that we can use index 1 for the uniforms if present. + // + // Also, we set buffers (and textures) for both stages. + // + // Again, not very flexible, but it's consistent and predictable, + // and we need to treat the uniforms as special because of OpenGL. + // + // TODO: Maybe in the future add info to the pipeline struct which + // allows it to define a mapping between provided buffers and + // what index they get set at for the vertex / fragment stage. + for (s.buffers[1..], 2..) |b, i| if (b) |buf| { + self.encoder.msgSend( + void, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, i) }, + ); + self.encoder.msgSend( + void, + objc.sel("setFragmentBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, i) }, + ); + }; + } + + // Set the uniforms as buffer index 1 if present. + if (s.uniforms) |buf| { + self.encoder.msgSend( + void, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 1) }, + ); + self.encoder.msgSend( + void, + objc.sel("setFragmentBuffer:offset:atIndex:"), + .{ buf.value, @as(c_ulong, 0), @as(c_ulong, 1) }, + ); + } + + // Set textures. + for (s.textures, 0..) |t, i| if (t) |tex| { + self.encoder.msgSend( + void, + objc.sel("setVertexTexture:atIndex:"), + .{ tex.texture.value, @as(c_ulong, i) }, + ); + self.encoder.msgSend( + void, + objc.sel("setFragmentTexture:atIndex:"), + .{ tex.texture.value, @as(c_ulong, i) }, + ); + }; + + // Set samplers. + for (s.samplers, 0..) |samp, i| if (samp) |sampler| { + self.encoder.msgSend( + void, + objc.sel("setFragmentSamplerState:atIndex:"), + .{ sampler.sampler.value, @as(c_ulong, i) }, + ); + }; + + // Draw! + self.encoder.msgSend( + void, + objc.sel("drawPrimitives:vertexStart:vertexCount:instanceCount:"), + .{ + @intFromEnum(s.draw.type), + @as(c_ulong, 0), + @as(c_ulong, s.draw.vertex_count), + @as(c_ulong, s.draw.instance_count), + }, + ); +} + +/// Complete this render pass. +/// This struct can no longer be used after calling this. +pub fn complete(self: *const Self) void { + self.encoder.msgSend(void, objc.sel("endEncoding"), .{}); +} diff --git a/vendor/libghostty-vt/src/renderer/metal/Sampler.zig b/vendor/libghostty-vt/src/renderer/metal/Sampler.zig new file mode 100644 index 00000000..593f9a86 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/metal/Sampler.zig @@ -0,0 +1,64 @@ +//! Wrapper for handling samplers. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const objc = @import("objc"); + +const mtl = @import("api.zig"); +const Metal = @import("../Metal.zig"); + +const log = std.log.scoped(.metal); + +/// Options for initializing a sampler. +pub const Options = struct { + /// MTLDevice + device: objc.Object, + min_filter: mtl.MTLSamplerMinMagFilter, + mag_filter: mtl.MTLSamplerMinMagFilter, + s_address_mode: mtl.MTLSamplerAddressMode, + t_address_mode: mtl.MTLSamplerAddressMode, +}; + +/// The underlying MTLSamplerState Object. +sampler: objc.Object, + +pub const Error = error{ + /// A Metal API call failed. + MetalFailed, +}; + +/// Initialize a sampler +pub fn init( + opts: Options, +) Error!Self { + // Create our descriptor + const desc = init: { + const Class = objc.getClass("MTLSamplerDescriptor").?; + const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); + const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); + break :init id_init; + }; + defer desc.release(); + + // Properties + desc.setProperty("minFilter", opts.min_filter); + desc.setProperty("magFilter", opts.mag_filter); + desc.setProperty("sAddressMode", opts.s_address_mode); + desc.setProperty("tAddressMode", opts.t_address_mode); + + // Create the sampler state + const id = opts.device.msgSend( + ?*anyopaque, + objc.sel("newSamplerStateWithDescriptor:"), + .{desc}, + ) orelse return error.MetalFailed; + + return .{ + .sampler = objc.Object.fromId(id), + }; +} + +pub fn deinit(self: Self) void { + self.sampler.release(); +} diff --git a/vendor/libghostty-vt/src/renderer/metal/Target.zig b/vendor/libghostty-vt/src/renderer/metal/Target.zig new file mode 100644 index 00000000..f20bb0b7 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/metal/Target.zig @@ -0,0 +1,108 @@ +//! Represents a render target. +//! +//! In this case, an IOSurface-backed MTLTexture. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const objc = @import("objc"); +const macos = @import("macos"); +const graphics = macos.graphics; +const IOSurface = macos.iosurface.IOSurface; + +const mtl = @import("api.zig"); + +const log = std.log.scoped(.metal); + +/// Options for initializing a Target +pub const Options = struct { + /// MTLDevice + device: objc.Object, + + /// Desired width + width: usize, + /// Desired height + height: usize, + + /// Pixel format for the MTLTexture + pixel_format: mtl.MTLPixelFormat, + /// Storage mode for the MTLTexture + storage_mode: mtl.MTLResourceOptions.StorageMode, +}; + +/// The underlying IOSurface. +surface: *IOSurface, + +/// The underlying MTLTexture. +texture: objc.Object, + +/// Current width of this target. +width: usize, +/// Current height of this target. +height: usize, + +pub fn init(opts: Options) !Self { + // We set our surface's color space to Display P3. + // This allows us to have "Apple-style" alpha blending, + // since it seems to be the case that Apple apps like + // Terminal and TextEdit render text in the display's + // color space using converted colors, which reduces, + // but does not fully eliminate blending artifacts. + const colorspace = try graphics.ColorSpace.createNamed(.displayP3); + defer colorspace.release(); + + const surface = try IOSurface.init(.{ + .width = @intCast(opts.width), + .height = @intCast(opts.height), + .pixel_format = .@"32BGRA", + .bytes_per_element = 4, + .colorspace = colorspace, + }); + + // Create our descriptor + const desc = init: { + const Class = objc.getClass("MTLTextureDescriptor").?; + const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); + const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); + break :init id_init; + }; + defer desc.release(); + + // Set our properties + desc.setProperty("width", @as(c_ulong, @intCast(opts.width))); + desc.setProperty("height", @as(c_ulong, @intCast(opts.height))); + desc.setProperty("pixelFormat", @intFromEnum(opts.pixel_format)); + desc.setProperty("usage", mtl.MTLTextureUsage{ .render_target = true }); + desc.setProperty( + "resourceOptions", + mtl.MTLResourceOptions{ + // Indicate that the CPU writes to this resource but never reads it. + .cpu_cache_mode = .write_combined, + .storage_mode = opts.storage_mode, + }, + ); + + const id = opts.device.msgSend( + ?*anyopaque, + objc.sel("newTextureWithDescriptor:iosurface:plane:"), + .{ + desc, + surface, + @as(c_ulong, 0), + }, + ) orelse return error.MetalFailed; + + const texture = objc.Object.fromId(id); + + return .{ + .surface = surface, + .texture = texture, + .width = opts.width, + .height = opts.height, + }; +} + +pub fn deinit(self: *Self) void { + self.surface.deinit(); + self.texture.release(); +} diff --git a/vendor/libghostty-vt/src/renderer/metal/Texture.zig b/vendor/libghostty-vt/src/renderer/metal/Texture.zig new file mode 100644 index 00000000..5042919a --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/metal/Texture.zig @@ -0,0 +1,202 @@ +//! Wrapper for handling textures. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../../quirks.zig").inlineAssert; +const objc = @import("objc"); + +const mtl = @import("api.zig"); +const Metal = @import("../Metal.zig"); + +const log = std.log.scoped(.metal); + +/// Options for initializing a texture. +pub const Options = struct { + /// MTLDevice + device: objc.Object, + pixel_format: mtl.MTLPixelFormat, + resource_options: mtl.MTLResourceOptions, + usage: mtl.MTLTextureUsage, +}; + +/// The underlying MTLTexture Object. +texture: objc.Object, + +/// The width of this texture. +width: usize, +/// The height of this texture. +height: usize, + +/// Bytes per pixel for this texture. +bpp: usize, + +pub const Error = error{ + /// A Metal API call failed. + MetalFailed, +}; + +/// Initialize a texture +pub fn init( + opts: Options, + width: usize, + height: usize, + data: ?[]const u8, +) Error!Self { + // Create our descriptor + const desc = init: { + const Class = objc.getClass("MTLTextureDescriptor").?; + const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{}); + const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{}); + break :init id_init; + }; + defer desc.release(); + + // Set our properties + desc.setProperty("pixelFormat", @intFromEnum(opts.pixel_format)); + desc.setProperty("width", @as(c_ulong, width)); + desc.setProperty("height", @as(c_ulong, height)); + desc.setProperty("resourceOptions", opts.resource_options); + desc.setProperty("usage", opts.usage); + + // Initialize + const id = opts.device.msgSend( + ?*anyopaque, + objc.sel("newTextureWithDescriptor:"), + .{desc}, + ) orelse return error.MetalFailed; + + const self: Self = .{ + .texture = objc.Object.fromId(id), + .width = width, + .height = height, + .bpp = bppOf(opts.pixel_format), + }; + + // If we have data, we set it here. + if (data) |d| { + assert(d.len == width * height * self.bpp); + try self.replaceRegion(0, 0, width, height, d); + } + + return self; +} + +pub fn deinit(self: Self) void { + self.texture.release(); +} + +/// Replace a region of the texture with the provided data. +/// +/// Does NOT check the dimensions of the data to ensure correctness. +pub fn replaceRegion( + self: Self, + x: usize, + y: usize, + width: usize, + height: usize, + data: []const u8, +) error{}!void { + self.texture.msgSend( + void, + objc.sel("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"), + .{ + mtl.MTLRegion{ + .origin = .{ .x = x, .y = y, .z = 0 }, + .size = .{ + .width = @intCast(width), + .height = @intCast(height), + .depth = 1, + }, + }, + @as(c_ulong, 0), + @as(*const anyopaque, data.ptr), + @as(c_ulong, self.bpp * width), + }, + ); +} + +/// Returns the bytes per pixel for the provided pixel format +fn bppOf(pixel_format: mtl.MTLPixelFormat) usize { + return switch (pixel_format) { + // Invalid + .invalid => @panic("invalid pixel format"), + + // Weird formats I was too lazy to get the sizes of + else => @panic("pixel format size unknown (unlikely that this format was actually used, could be memory corruption)"), + + // 8-bit pixel formats + .a8unorm, + .r8unorm, + .r8unorm_srgb, + .r8snorm, + .r8uint, + .r8sint, + .rg8unorm, + .rg8unorm_srgb, + .rg8snorm, + .rg8uint, + .rg8sint, + .stencil8, + => 1, + + // 16-bit pixel formats + .r16unorm, + .r16snorm, + .r16uint, + .r16sint, + .r16float, + .rg16unorm, + .rg16snorm, + .rg16uint, + .rg16sint, + .rg16float, + .b5g6r5unorm, + .a1bgr5unorm, + .abgr4unorm, + .bgr5a1unorm, + .depth16unorm, + => 2, + + // 32-bit pixel formats + .rgba8unorm, + .rgba8unorm_srgb, + .rgba8snorm, + .rgba8uint, + .rgba8sint, + .bgra8unorm, + .bgra8unorm_srgb, + .rgb10a2unorm, + .rgb10a2uint, + .rg11b10float, + .rgb9e5float, + .bgr10a2unorm, + .bgr10_xr, + .bgr10_xr_srgb, + .r32uint, + .r32sint, + .r32float, + .depth32float, + .depth24unorm_stencil8, + => 4, + + // 64-bit pixel formats + .rg32uint, + .rg32sint, + .rg32float, + .rgba16unorm, + .rgba16snorm, + .rgba16uint, + .rgba16sint, + .rgba16float, + .bgra10_xr, + .bgra10_xr_srgb, + => 8, + + // 128-bit pixel formats, + .rgba32uint, + .rgba32sint, + .rgba32float, + => 128, + }; +} diff --git a/vendor/libghostty-vt/src/renderer/metal/api.zig b/vendor/libghostty-vt/src/renderer/metal/api.zig new file mode 100644 index 00000000..a2d8a135 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/metal/api.zig @@ -0,0 +1,452 @@ +//! This file contains the definitions of the Metal API that we use. +//! +//! Because the online Apple developer docs have recently (as of January 2025) +//! been changed to hide enum values, `Metal-cpp` has been used as a reference +//! source instead. +//! +//! Ref: https://developer.apple.com/metal/cpp/ + +/// https://developer.apple.com/documentation/metal/mtlcommandbufferstatus?language=objc +pub const MTLCommandBufferStatus = enum(c_ulong) { + not_enqueued = 0, + enqueued = 1, + committed = 2, + scheduled = 3, + completed = 4, + @"error" = 5, + _, +}; + +/// https://developer.apple.com/documentation/metal/mtlloadaction?language=objc +pub const MTLLoadAction = enum(c_ulong) { + dont_care = 0, + load = 1, + clear = 2, +}; + +/// https://developer.apple.com/documentation/metal/mtlstoreaction?language=objc +pub const MTLStoreAction = enum(c_ulong) { + dont_care = 0, + store = 1, + multisample_resolve = 2, + store_and_multisample_resolve = 3, + unknown = 4, + custom_sample_depth_store = 5, +}; + +/// https://developer.apple.com/documentation/metal/mtlresourceoptions?language=objc +pub const MTLResourceOptions = packed struct(c_ulong) { + /// https://developer.apple.com/documentation/metal/mtlcpucachemode?language=objc + cpu_cache_mode: CPUCacheMode = .default, + /// https://developer.apple.com/documentation/metal/mtlstoragemode?language=objc + storage_mode: StorageMode, + /// https://developer.apple.com/documentation/metal/mtlhazardtrackingmode?language=objc + hazard_tracking_mode: HazardTrackingMode = .default, + + _pad: @Type(.{ + .int = .{ .signedness = .unsigned, .bits = @bitSizeOf(c_ulong) - 10 }, + }) = 0, + + pub const CPUCacheMode = enum(u4) { + default = 0, + write_combined = 1, + }; + + pub const StorageMode = enum(u4) { + shared = 0, + managed = 1, + private = 2, + memoryless = 3, + }; + + pub const HazardTrackingMode = enum(u2) { + default = 0, + untracked = 1, + tracked = 2, + }; +}; + +/// https://developer.apple.com/documentation/metal/mtlprimitivetype?language=objc +pub const MTLPrimitiveType = enum(c_ulong) { + point = 0, + line = 1, + line_strip = 2, + triangle = 3, + triangle_strip = 4, +}; + +/// https://developer.apple.com/documentation/metal/mtlindextype?language=objc +pub const MTLIndexType = enum(c_ulong) { + uint16 = 0, + uint32 = 1, +}; + +/// https://developer.apple.com/documentation/metal/mtlvertexformat?language=objc +pub const MTLVertexFormat = enum(c_ulong) { + invalid = 0, + uchar2 = 1, + uchar3 = 2, + uchar4 = 3, + char2 = 4, + char3 = 5, + char4 = 6, + uchar2normalized = 7, + uchar3normalized = 8, + uchar4normalized = 9, + char2normalized = 10, + char3normalized = 11, + char4normalized = 12, + ushort2 = 13, + ushort3 = 14, + ushort4 = 15, + short2 = 16, + short3 = 17, + short4 = 18, + ushort2normalized = 19, + ushort3normalized = 20, + ushort4normalized = 21, + short2normalized = 22, + short3normalized = 23, + short4normalized = 24, + half2 = 25, + half3 = 26, + half4 = 27, + float = 28, + float2 = 29, + float3 = 30, + float4 = 31, + int = 32, + int2 = 33, + int3 = 34, + int4 = 35, + uint = 36, + uint2 = 37, + uint3 = 38, + uint4 = 39, + int1010102normalized = 40, + uint1010102normalized = 41, + uchar4normalized_bgra = 42, + uchar = 45, + char = 46, + ucharnormalized = 47, + charnormalized = 48, + ushort = 49, + short = 50, + ushortnormalized = 51, + shortnormalized = 52, + half = 53, + floatrg11b10 = 54, + floatrgb9e5 = 55, +}; + +/// https://developer.apple.com/documentation/metal/mtlvertexstepfunction?language=objc +pub const MTLVertexStepFunction = enum(c_ulong) { + constant = 0, + per_vertex = 1, + per_instance = 2, + per_patch = 3, + per_patch_control_point = 4, +}; + +/// https://developer.apple.com/documentation/metal/mtlpixelformat?language=objc +pub const MTLPixelFormat = enum(c_ulong) { + invalid = 0, + a8unorm = 1, + r8unorm = 10, + r8unorm_srgb = 11, + r8snorm = 12, + r8uint = 13, + r8sint = 14, + r16unorm = 20, + r16snorm = 22, + r16uint = 23, + r16sint = 24, + r16float = 25, + rg8unorm = 30, + rg8unorm_srgb = 31, + rg8snorm = 32, + rg8uint = 33, + rg8sint = 34, + b5g6r5unorm = 40, + a1bgr5unorm = 41, + abgr4unorm = 42, + bgr5a1unorm = 43, + r32uint = 53, + r32sint = 54, + r32float = 55, + rg16unorm = 60, + rg16snorm = 62, + rg16uint = 63, + rg16sint = 64, + rg16float = 65, + rgba8unorm = 70, + rgba8unorm_srgb = 71, + rgba8snorm = 72, + rgba8uint = 73, + rgba8sint = 74, + bgra8unorm = 80, + bgra8unorm_srgb = 81, + rgb10a2unorm = 90, + rgb10a2uint = 91, + rg11b10float = 92, + rgb9e5float = 93, + bgr10a2unorm = 94, + bgr10_xr = 554, + bgr10_xr_srgb = 555, + rg32uint = 103, + rg32sint = 104, + rg32float = 105, + rgba16unorm = 110, + rgba16snorm = 112, + rgba16uint = 113, + rgba16sint = 114, + rgba16float = 115, + bgra10_xr = 552, + bgra10_xr_srgb = 553, + rgba32uint = 123, + rgba32sint = 124, + rgba32float = 125, + bc1_rgba = 130, + bc1_rgba_srgb = 131, + bc2_rgba = 132, + bc2_rgba_srgb = 133, + bc3_rgba = 134, + bc3_rgba_srgb = 135, + bc4_runorm = 140, + bc4_rsnorm = 141, + bc5_rgunorm = 142, + bc5_rgsnorm = 143, + bc6h_rgbfloat = 150, + bc6h_rgbufloat = 151, + bc7_rgbaunorm = 152, + bc7_rgbaunorm_srgb = 153, + pvrtc_rgb_2bpp = 160, + pvrtc_rgb_2bpp_srgb = 161, + pvrtc_rgb_4bpp = 162, + pvrtc_rgb_4bpp_srgb = 163, + pvrtc_rgba_2bpp = 164, + pvrtc_rgba_2bpp_srgb = 165, + pvrtc_rgba_4bpp = 166, + pvrtc_rgba_4bpp_srgb = 167, + eac_r11unorm = 170, + eac_r11snorm = 172, + eac_rg11unorm = 174, + eac_rg11snorm = 176, + eac_rgba8 = 178, + eac_rgba8_srgb = 179, + etc2_rgb8 = 180, + etc2_rgb8_srgb = 181, + etc2_rgb8a1 = 182, + etc2_rgb8a1_srgb = 183, + astc_4x4_srgb = 186, + astc_5x4_srgb = 187, + astc_5x5_srgb = 188, + astc_6x5_srgb = 189, + astc_6x6_srgb = 190, + astc_8x5_srgb = 192, + astc_8x6_srgb = 193, + astc_8x8_srgb = 194, + astc_10x5_srgb = 195, + astc_10x6_srgb = 196, + astc_10x8_srgb = 197, + astc_10x10_srgb = 198, + astc_12x10_srgb = 199, + astc_12x12_srgb = 200, + astc_4x4_ldr = 204, + astc_5x4_ldr = 205, + astc_5x5_ldr = 206, + astc_6x5_ldr = 207, + astc_6x6_ldr = 208, + astc_8x5_ldr = 210, + astc_8x6_ldr = 211, + astc_8x8_ldr = 212, + astc_10x5_ldr = 213, + astc_10x6_ldr = 214, + astc_10x8_ldr = 215, + astc_10x10_ldr = 216, + astc_12x10_ldr = 217, + astc_12x12_ldr = 218, + astc_4x4_hdr = 222, + astc_5x4_hdr = 223, + astc_5x5_hdr = 224, + astc_6x5_hdr = 225, + astc_6x6_hdr = 226, + astc_8x5_hdr = 228, + astc_8x6_hdr = 229, + astc_8x8_hdr = 230, + astc_10x5_hdr = 231, + astc_10x6_hdr = 232, + astc_10x8_hdr = 233, + astc_10x10_hdr = 234, + astc_12x10_hdr = 235, + astc_12x12_hdr = 236, + gbgr422 = 240, + bgrg422 = 241, + depth16unorm = 250, + depth32float = 252, + stencil8 = 253, + depth24unorm_stencil8 = 255, + depth32float_stencil8 = 260, + x32_stencil8 = 261, + x24_stencil8 = 262, +}; + +/// https://developer.apple.com/documentation/metal/mtlpurgeablestate?language=objc +pub const MTLPurgeableState = enum(c_ulong) { + keep_current = 1, + non_volatile = 2, + @"volatile" = 3, + empty = 4, +}; + +/// https://developer.apple.com/documentation/metal/mtlsamplerminmagfilter?language=objc +pub const MTLSamplerMinMagFilter = enum(c_ulong) { + nearest = 0, + linear = 1, +}; + +/// https://developer.apple.com/documentation/metal/mtlsampleraddressmode?language=objc +pub const MTLSamplerAddressMode = enum(c_ulong) { + clamp_to_edge = 0, + mirror_clamp_to_edge = 1, + repeat = 2, + mirror_repeat = 3, + clamp_to_zero = 4, + clamp_to_border_color = 5, +}; + +/// https://developer.apple.com/documentation/metal/mtlblendfactor?language=objc +pub const MTLBlendFactor = enum(c_ulong) { + zero = 0, + one = 1, + source_color = 2, + one_minus_source_color = 3, + source_alpha = 4, + one_minus_source_alpha = 5, + dest_color = 6, + one_minus_dest_color = 7, + dest_alpha = 8, + one_minus_dest_alpha = 9, + source_alpha_saturated = 10, + blend_color = 11, + one_minus_blend_color = 12, + blend_alpha = 13, + one_minus_blend_alpha = 14, + source_1_color = 15, + one_minus_source_1_color = 16, + source_1_alpha = 17, + one_minus_source_1_alpha = 18, +}; + +/// https://developer.apple.com/documentation/metal/mtlblendoperation?language=objc +pub const MTLBlendOperation = enum(c_ulong) { + add = 0, + subtract = 1, + reverse_subtract = 2, + min = 3, + max = 4, +}; + +/// https://developer.apple.com/documentation/metal/mtltextureusage?language=objc +pub const MTLTextureUsage = packed struct(c_ulong) { + /// https://developer.apple.com/documentation/metal/mtltextureusage/shaderread?language=objc + shader_read: bool = false, // TextureUsageShaderRead = 1, + + /// https://developer.apple.com/documentation/metal/mtltextureusage/shaderwrite?language=objc + shader_write: bool = false, // TextureUsageShaderWrite = 2, + + /// https://developer.apple.com/documentation/metal/mtltextureusage/rendertarget?language=objc + render_target: bool = false, // TextureUsageRenderTarget = 4, + + _reserved: u1 = 0, // The enum skips from 4 to 16, 8 has no documented use. + + /// https://developer.apple.com/documentation/metal/mtltextureusage/pixelformatview?language=objc + pixel_format_view: bool = false, // TextureUsagePixelFormatView = 16, + + /// https://developer.apple.com/documentation/metal/mtltextureusage/shaderatomic?language=objc + shader_atomic: bool = false, // TextureUsageShaderAtomic = 32, + + __reserved: @Type(.{ .int = .{ + .signedness = .unsigned, + .bits = @bitSizeOf(c_ulong) - 6, + } }) = 0, + + /// https://developer.apple.com/documentation/metal/mtltextureusage/unknown?language=objc + const unknown: MTLTextureUsage = @bitCast(0); // TextureUsageUnknown = 0, +}; + +/// https://developer.apple.com/documentation/metal/mtlbarrierscope?language=objc +pub const MTLBarrierScope = enum(c_ulong) { + buffers = 1, + textures = 2, + render_targets = 4, +}; + +/// https://developer.apple.com/documentation/metal/mtlrenderstages?language=objc +pub const MTLRenderStage = enum(c_ulong) { + vertex = 1, + fragment = 2, + tile = 4, + object = 8, + mesh = 16, +}; + +/// https://developer.apple.com/documentation/metal/mtlgpufamily?language=objc +pub const MTLGPUFamily = enum(c_long) { + apple1 = 1001, + apple2 = 1002, + apple3 = 1003, + apple4 = 1004, + apple5 = 1005, + apple6 = 1006, + apple7 = 1007, + apple8 = 1008, + apple9 = 1009, + apple10 = 1010, + + common1 = 3001, + common2 = 3002, + common3 = 3003, + + metal3 = 5001, + metal4 = 5002, +}; + +pub const MTLClearColor = extern struct { + red: f64, + green: f64, + blue: f64, + alpha: f64, +}; + +pub const MTLViewport = extern struct { + x: f64, + y: f64, + width: f64, + height: f64, + znear: f64, + zfar: f64, +}; + +pub const MTLRegion = extern struct { + origin: MTLOrigin, + size: MTLSize, +}; + +pub const MTLOrigin = extern struct { + x: c_ulong, + y: c_ulong, + z: c_ulong, +}; + +pub const MTLSize = extern struct { + width: c_ulong, + height: c_ulong, + depth: c_ulong, +}; + +/// https://developer.apple.com/documentation/metal/1433367-mtlcopyalldevices +pub extern "c" fn MTLCopyAllDevices() ?*anyopaque; + +/// https://developer.apple.com/documentation/metal/1433401-mtlcreatesystemdefaultdevice +pub extern "c" fn MTLCreateSystemDefaultDevice() ?*anyopaque; diff --git a/vendor/libghostty-vt/src/renderer/metal/buffer.zig b/vendor/libghostty-vt/src/renderer/metal/buffer.zig new file mode 100644 index 00000000..f91f89e9 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/metal/buffer.zig @@ -0,0 +1,185 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const objc = @import("objc"); +const macos = @import("macos"); + +const mtl = @import("api.zig"); +const Metal = @import("../Metal.zig"); + +const log = std.log.scoped(.metal); + +/// Options for initializing a buffer. +pub const Options = struct { + /// MTLDevice + device: objc.Object, + resource_options: mtl.MTLResourceOptions, +}; + +/// Metal data storage for a certain set of equal types. This is usually +/// used for vertex buffers, etc. This helpful wrapper makes it easy to +/// prealloc, shrink, grow, sync, buffers with Metal. +pub fn Buffer(comptime T: type) type { + return struct { + const Self = @This(); + + /// The options this buffer was initialized with. + opts: Options, + + /// The underlying MTLBuffer object. + buffer: objc.Object, + + /// The allocated length of the buffer. + /// Note that this is the number + /// of `T`s not the size in bytes. + len: usize, + + /// Initialize a buffer with the given length pre-allocated. + pub fn init(opts: Options, len: usize) !Self { + const buffer = opts.device.msgSend( + objc.Object, + objc.sel("newBufferWithLength:options:"), + .{ + @as(c_ulong, @intCast(len * @sizeOf(T))), + opts.resource_options, + }, + ); + + return .{ .buffer = buffer, .opts = opts, .len = len }; + } + + /// Init the buffer filled with the given data. + pub fn initFill(opts: Options, data: []const T) !Self { + const buffer = opts.device.msgSend( + objc.Object, + objc.sel("newBufferWithBytes:length:options:"), + .{ + @as(*const anyopaque, @ptrCast(data.ptr)), + @as(c_ulong, @intCast(data.len * @sizeOf(T))), + opts.resource_options, + }, + ); + + return .{ .buffer = buffer, .opts = opts, .len = data.len }; + } + + pub fn deinit(self: *const Self) void { + self.buffer.msgSend(void, objc.sel("release"), .{}); + } + + /// Sync new contents to the buffer. The data is expected to be the + /// complete contents of the buffer. If the amount of data is larger + /// than the buffer length, the buffer will be reallocated. + /// + /// If the amount of data is smaller than the buffer length, the + /// remaining data in the buffer is left untouched. + pub fn sync(self: *Self, data: []const T) !void { + // If we need more bytes than our buffer has, we need to reallocate. + const req_bytes = data.len * @sizeOf(T); + const avail_bytes = self.buffer.getProperty(c_ulong, "length"); + if (req_bytes > avail_bytes) { + // Deallocate previous buffer + self.buffer.msgSend(void, objc.sel("release"), .{}); + + // Allocate a new buffer with enough to hold double what we require. + const size = req_bytes * 2; + self.buffer = self.opts.device.msgSend( + objc.Object, + objc.sel("newBufferWithLength:options:"), + .{ + @as(c_ulong, @intCast(size * @sizeOf(T))), + self.opts.resource_options, + }, + ); + } + + // We can fit within the buffer so we can just replace bytes. + const dst = dst: { + const ptr = self.buffer.msgSend(?[*]u8, objc.sel("contents"), .{}) orelse { + log.warn("buffer contents ptr is null", .{}); + return error.MetalFailed; + }; + + break :dst ptr[0..req_bytes]; + }; + + const src = src: { + const ptr = @as([*]const u8, @ptrCast(data.ptr)); + break :src ptr[0..req_bytes]; + }; + + @memcpy(dst, src); + + // If we're using the managed resource storage mode, then + // we need to signal Metal to synchronize the buffer data. + // + // Ref: https://developer.apple.com/documentation/metal/synchronizing-a-managed-resource-in-macos?language=objc + if (self.opts.resource_options.storage_mode == .managed) { + self.buffer.msgSend( + void, + "didModifyRange:", + .{macos.foundation.Range.init(0, req_bytes)}, + ); + } + } + + /// Like Buffer.sync but takes data from an array of ArrayLists, + /// rather than a single array. Returns the number of items synced. + pub fn syncFromArrayLists(self: *Self, lists: []const std.ArrayListUnmanaged(T)) !usize { + var total_len: usize = 0; + for (lists) |list| { + total_len += list.items.len; + } + + // If we need more bytes than our buffer has, we need to reallocate. + const req_bytes = total_len * @sizeOf(T); + const avail_bytes = self.buffer.getProperty(c_ulong, "length"); + if (req_bytes > avail_bytes) { + // Deallocate previous buffer + self.buffer.msgSend(void, objc.sel("release"), .{}); + + // Allocate a new buffer with enough to hold double what we require. + const size = req_bytes * 2; + self.buffer = self.opts.device.msgSend( + objc.Object, + objc.sel("newBufferWithLength:options:"), + .{ + @as(c_ulong, @intCast(size * @sizeOf(T))), + self.opts.resource_options, + }, + ); + } + + // We can fit within the buffer so we can just replace bytes. + const dst = dst: { + const ptr = self.buffer.msgSend(?[*]u8, objc.sel("contents"), .{}) orelse { + log.warn("buffer contents ptr is null", .{}); + return error.MetalFailed; + }; + + break :dst ptr[0..req_bytes]; + }; + + var i: usize = 0; + + for (lists) |list| { + const ptr = @as([*]const u8, @ptrCast(list.items.ptr)); + @memcpy(dst[i..][0 .. list.items.len * @sizeOf(T)], ptr); + i += list.items.len * @sizeOf(T); + } + + // If we're using the managed resource storage mode, then + // we need to signal Metal to synchronize the buffer data. + // + // Ref: https://developer.apple.com/documentation/metal/synchronizing-a-managed-resource-in-macos?language=objc + if (self.opts.resource_options.storage_mode == .managed) { + self.buffer.msgSend( + void, + "didModifyRange:", + .{macos.foundation.Range.init(0, req_bytes)}, + ); + } + + return total_len; + } + }; +} diff --git a/vendor/libghostty-vt/src/renderer/metal/shaders.zig b/vendor/libghostty-vt/src/renderer/metal/shaders.zig new file mode 100644 index 00000000..0be02357 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/metal/shaders.zig @@ -0,0 +1,454 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const macos = @import("macos"); +const objc = @import("objc"); +const math = @import("../../math.zig"); + +const mtl = @import("api.zig"); +const Pipeline = @import("Pipeline.zig"); + +const log = std.log.scoped(.metal); + +const pipeline_descs: []const struct { [:0]const u8, PipelineDescription } = + &.{ + .{ "bg_color", .{ + .vertex_fn = "full_screen_vertex", + .fragment_fn = "bg_color_fragment", + .blending_enabled = false, + } }, + .{ "cell_bg", .{ + .vertex_fn = "full_screen_vertex", + .fragment_fn = "cell_bg_fragment", + .blending_enabled = true, + } }, + .{ "cell_text", .{ + .vertex_attributes = CellText, + .vertex_fn = "cell_text_vertex", + .fragment_fn = "cell_text_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "image", .{ + .vertex_attributes = Image, + .vertex_fn = "image_vertex", + .fragment_fn = "image_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "bg_image", .{ + .vertex_attributes = BgImage, + .vertex_fn = "bg_image_vertex", + .fragment_fn = "bg_image_fragment", + .step_fn = .per_instance, + .blending_enabled = true, + } }, + }; + +/// All the comptime-known info about a pipeline, so that +/// we can define them ahead-of-time in an ergonomic way. +const PipelineDescription = struct { + vertex_attributes: ?type = null, + vertex_fn: []const u8, + fragment_fn: []const u8, + step_fn: mtl.MTLVertexStepFunction = .per_vertex, + blending_enabled: bool, + + fn initPipeline( + self: PipelineDescription, + device: objc.Object, + library: objc.Object, + pixel_format: mtl.MTLPixelFormat, + ) !Pipeline { + return try .init(self.vertex_attributes, .{ + .device = device, + .vertex_fn = self.vertex_fn, + .fragment_fn = self.fragment_fn, + .vertex_library = library, + .fragment_library = library, + .step_fn = self.step_fn, + .attachments = &.{.{ + .pixel_format = pixel_format, + .blending_enabled = self.blending_enabled, + }}, + }); + } +}; + +/// We create a type for the pipeline collection based on our desc array. +const PipelineCollection = t: { + var fields: [pipeline_descs.len]std.builtin.Type.StructField = undefined; + for (pipeline_descs, 0..) |pipeline, i| { + fields[i] = .{ + .name = pipeline[0], + .type = Pipeline, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(Pipeline), + }; + } + break :t @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +}; + +/// This contains the state for the shaders used by the Metal renderer. +pub const Shaders = struct { + library: objc.Object, + + /// Collection of available render pipelines. + pipelines: PipelineCollection, + + /// Custom shaders to run against the final drawable texture. This + /// can be used to apply a lot of effects. Each shader is run in sequence + /// against the output of the previous shader. + post_pipelines: []const Pipeline, + + /// Set to true when deinited, if you try to deinit a defunct set + /// of shaders it will just be ignored, to prevent double-free. + defunct: bool = false, + + /// Initialize our shader set. + /// + /// "post_shaders" is an optional list of postprocess shaders to run + /// against the final drawable texture. This is an array of shader source + /// code, not file paths. + pub fn init( + alloc: Allocator, + device: objc.Object, + post_shaders: []const [:0]const u8, + pixel_format: mtl.MTLPixelFormat, + ) !Shaders { + const library = try initLibrary(device); + errdefer library.msgSend(void, objc.sel("release"), .{}); + + var pipelines: PipelineCollection = undefined; + + var initialized_pipelines: usize = 0; + + errdefer inline for (pipeline_descs, 0..) |pipeline, i| { + if (i < initialized_pipelines) { + @field(pipelines, pipeline[0]).deinit(); + } + }; + + inline for (pipeline_descs) |pipeline| { + @field(pipelines, pipeline[0]) = try pipeline[1].initPipeline( + device, + library, + pixel_format, + ); + initialized_pipelines += 1; + } + + const post_pipelines: []const Pipeline = initPostPipelines( + alloc, + device, + library, + post_shaders, + pixel_format, + ) catch |err| err: { + // If an error happens while building postprocess shaders we + // want to just not use any postprocess shaders since we don't + // want to block Ghostty from working. + log.warn("error initializing postprocess shaders err={}", .{err}); + break :err &.{}; + }; + errdefer if (post_pipelines.len > 0) { + for (post_pipelines) |pipeline| pipeline.deinit(); + alloc.free(post_pipelines); + }; + + return .{ + .library = library, + .pipelines = pipelines, + .post_pipelines = post_pipelines, + }; + } + + pub fn deinit(self: *Shaders, alloc: Allocator) void { + if (self.defunct) return; + self.defunct = true; + + // Release our primary shaders + inline for (pipeline_descs) |pipeline| { + @field(self.pipelines, pipeline[0]).deinit(); + } + self.library.msgSend(void, objc.sel("release"), .{}); + + // Release our postprocess shaders + if (self.post_pipelines.len > 0) { + for (self.post_pipelines) |pipeline| { + pipeline.deinit(); + } + alloc.free(self.post_pipelines); + } + } +}; + +/// The uniforms that are passed to our shaders. +pub const Uniforms = extern struct { + // Note: all of the explicit alignments are copied from the + // MSL developer reference just so that we can be sure that we got + // it all exactly right. + + /// The projection matrix for turning world coordinates to normalized. + /// This is calculated based on the size of the screen. + projection_matrix: math.Mat align(16), + + /// Size of the screen (render target) in pixels. + screen_size: [2]f32 align(8), + + /// Size of a single cell in pixels, unscaled. + cell_size: [2]f32 align(8), + + /// Size of the grid in columns and rows. + grid_size: [2]u16 align(4), + + /// The padding around the terminal grid in pixels. In order: + /// top, right, bottom, left. + grid_padding: [4]f32 align(16), + + /// Bit mask defining which directions to + /// extend cell colors in to the padding. + /// Order, LSB first: left, right, up, down + padding_extend: PaddingExtend align(1), + + /// The minimum contrast ratio for text. The contrast ratio is calculated + /// according to the WCAG 2.0 spec. + min_contrast: f32 align(4), + + /// The cursor position and color. + cursor_pos: [2]u16 align(4), + cursor_color: [4]u8 align(4), + + /// The background color for the whole surface. + bg_color: [4]u8 align(4), + + /// Various booleans. + /// + /// TODO: Maybe put these in a packed struct, like for OpenGL. + bools: extern struct { + /// Whether the cursor is 2 cells wide. + cursor_wide: bool align(1), + + /// Indicates that colors provided to the shader are already in + /// the P3 color space, so they don't need to be converted from + /// sRGB. + use_display_p3: bool align(1), + + /// Indicates that the color attachments for the shaders have + /// an `*_srgb` pixel format, which means the shaders need to + /// output linear RGB colors rather than gamma encoded colors, + /// since blending will be performed in linear space and then + /// Metal itself will re-encode the colors for storage. + use_linear_blending: bool align(1), + + /// Enables a weight correction step that makes text rendered + /// with linear alpha blending have a similar apparent weight + /// (thickness) to gamma-incorrect blending. + use_linear_correction: bool align(1) = false, + }, + + const PaddingExtend = packed struct(u8) { + left: bool = false, + right: bool = false, + up: bool = false, + down: bool = false, + _padding: u4 = 0, + }; +}; + +/// This is a single parameter for the terminal cell shader. +pub const CellText = extern struct { + glyph_pos: [2]u32 align(8) = .{ 0, 0 }, + glyph_size: [2]u32 align(8) = .{ 0, 0 }, + bearings: [2]i16 align(4) = .{ 0, 0 }, + grid_pos: [2]u16 align(4), + color: [4]u8 align(4), + atlas: Atlas align(1), + bools: packed struct(u8) { + no_min_contrast: bool = false, + is_cursor_glyph: bool = false, + _padding: u6 = 0, + } align(1) = .{}, + + pub const Atlas = enum(u8) { + grayscale = 0, + color = 1, + }; + + test { + // Minimizing the size of this struct is important, + // so we test it in order to be aware of any changes. + try std.testing.expectEqual(32, @sizeOf(CellText)); + } +}; + +/// This is a single parameter for the cell bg shader. +pub const CellBg = [4]u8; + +/// Single parameter for the image shader. See shader for field details. +pub const Image = extern struct { + grid_pos: [2]f32, + cell_offset: [2]f32, + source_rect: [4]f32, + dest_size: [2]f32, +}; + +/// Single parameter for the bg image shader. +pub const BgImage = extern struct { + opacity: f32 align(4), + info: Info align(1), + + pub const Info = packed struct(u8) { + position: Position, + fit: Fit, + repeat: bool, + _padding: u1 = 0, + + pub const Position = enum(u4) { + tl = 0, + tc = 1, + tr = 2, + ml = 3, + mc = 4, + mr = 5, + bl = 6, + bc = 7, + br = 8, + }; + + pub const Fit = enum(u2) { + contain = 0, + cover = 1, + stretch = 2, + none = 3, + }; + }; +}; + +/// Initialize the MTLLibrary. A MTLLibrary is a collection of shaders. +fn initLibrary(device: objc.Object) !objc.Object { + const start = try std.time.Instant.now(); + + const data = try macos.dispatch.Data.create( + @embedFile("ghostty_metallib"), + macos.dispatch.queue.getMain(), + macos.dispatch.Data.DESTRUCTOR_DEFAULT, + ); + defer data.release(); + + var err: ?*anyopaque = null; + const library = device.msgSend( + objc.Object, + objc.sel("newLibraryWithData:error:"), + .{ + data, + &err, + }, + ); + try checkError(err); + + const end = try std.time.Instant.now(); + log.debug("shader library loaded time={}us", .{end.since(start) / std.time.ns_per_us}); + + return library; +} + +/// Initialize our custom shader pipelines. +/// +/// The shaders argument is a set of shader source code, not file paths. +fn initPostPipelines( + alloc: Allocator, + device: objc.Object, + library: objc.Object, + shaders: []const [:0]const u8, + pixel_format: mtl.MTLPixelFormat, +) ![]const Pipeline { + // If we have no shaders, do nothing. + if (shaders.len == 0) return &.{}; + + // Keeps track of how many shaders we successfully wrote. + var i: usize = 0; + + // Initialize our result set. If any error happens, we undo everything. + var pipelines = try alloc.alloc(Pipeline, shaders.len); + errdefer { + for (pipelines[0..i]) |pipeline| { + pipeline.deinit(); + } + alloc.free(pipelines); + } + + // Build each shader. Note we don't use "0.." to build our index + // because we need to keep track of our length to clean up above. + for (shaders) |source| { + pipelines[i] = try initPostPipeline( + device, + library, + source, + pixel_format, + ); + i += 1; + } + + return pipelines; +} + +/// Initialize a single custom shader pipeline from shader source. +fn initPostPipeline( + device: objc.Object, + library: objc.Object, + data: [:0]const u8, + pixel_format: mtl.MTLPixelFormat, +) !Pipeline { + // Create our library which has the shader source + const post_library = library: { + const source = try macos.foundation.String.createWithBytes( + data, + .utf8, + false, + ); + defer source.release(); + + var err: ?*anyopaque = null; + const post_library = device.msgSend( + objc.Object, + objc.sel("newLibraryWithSource:options:error:"), + .{ source, @as(?*anyopaque, null), &err }, + ); + try checkError(err); + errdefer post_library.msgSend(void, objc.sel("release"), .{}); + + break :library post_library; + }; + defer post_library.msgSend(void, objc.sel("release"), .{}); + + return try Pipeline.init(null, .{ + .device = device, + .vertex_fn = "full_screen_vertex", + .fragment_fn = "main0", + .vertex_library = library, + .fragment_library = post_library, + .attachments = &.{ + .{ + .pixel_format = pixel_format, + .blending_enabled = false, + }, + }, + }); +} + +fn checkError(err_: ?*anyopaque) !void { + const nserr = objc.Object.fromId(err_ orelse return); + const str = @as( + *macos.foundation.String, + @ptrCast(nserr.getProperty(?*anyopaque, "localizedDescription").?), + ); + + log.err("metal error={s}", .{str.cstringPtr(.ascii).?}); + return error.MetalFailed; +} diff --git a/vendor/libghostty-vt/src/renderer/opengl/Frame.zig b/vendor/libghostty-vt/src/renderer/opengl/Frame.zig new file mode 100644 index 00000000..289413b0 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/opengl/Frame.zig @@ -0,0 +1,71 @@ +//! Wrapper for handling render passes. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const gl = @import("opengl"); + +const Renderer = @import("../generic.zig").Renderer(OpenGL); +const OpenGL = @import("../OpenGL.zig"); +const Target = @import("Target.zig"); +const RenderPass = @import("RenderPass.zig"); + +const Health = @import("../../renderer.zig").Health; + +const log = std.log.scoped(.opengl); + +/// Options for beginning a frame. +pub const Options = struct {}; + +renderer: *Renderer, +target: *Target, + +/// Begin encoding a frame. +pub fn begin( + opts: Options, + /// Once the frame has been completed, the `frameCompleted` method + /// on the renderer is called with the health status of the frame. + renderer: *Renderer, + /// The target is presented via the provided renderer's API when completed. + target: *Target, +) !Self { + _ = opts; + + return .{ + .renderer = renderer, + .target = target, + }; +} + +/// Add a render pass to this frame with the provided attachments. +/// Returns a RenderPass which allows render steps to be added. +pub inline fn renderPass( + self: *const Self, + attachments: []const RenderPass.Options.Attachment, +) RenderPass { + _ = self; + return RenderPass.begin(.{ .attachments = attachments }); +} + +/// Complete this frame and present the target. +/// +/// If `sync` is true, this will block until the frame is presented. +/// +/// NOTE: For OpenGL, `sync` is ignored and we always block. +pub fn complete(self: *const Self, sync: bool) void { + _ = sync; + gl.finish(); + + // If there are any GL errors, consider the frame unhealthy. + const health: Health = if (gl.errors.getError()) .healthy else |_| .unhealthy; + + // If the frame is healthy, present it. + if (health == .healthy) { + self.renderer.api.present(self.target.*) catch |err| { + log.err("Failed to present render target: err={}", .{err}); + }; + } + + // Report the health to the renderer. + self.renderer.frameCompleted(health); +} diff --git a/vendor/libghostty-vt/src/renderer/opengl/Pipeline.zig b/vendor/libghostty-vt/src/renderer/opengl/Pipeline.zig new file mode 100644 index 00000000..2469f45b --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/opengl/Pipeline.zig @@ -0,0 +1,164 @@ +//! Wrapper for handling render pipelines. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const gl = @import("opengl"); + +const log = std.log.scoped(.opengl); + +/// Options for initializing a render pipeline. +pub const Options = struct { + /// GLSL source of the vertex function + vertex_fn: [:0]const u8, + /// GLSL source of the fragment function + fragment_fn: [:0]const u8, + + /// Vertex step function + step_fn: StepFunction = .per_vertex, + + /// Whether to enable blending. + blending_enabled: bool = true, + + pub const StepFunction = enum { + constant, + per_vertex, + per_instance, + }; +}; + +program: gl.Program, + +fbo: gl.Framebuffer, + +vao: gl.VertexArray, + +stride: usize, + +blending_enabled: bool, + +pub fn init(comptime VertexAttributes: ?type, opts: Options) !Self { + // Load and compile our shaders. + const program = try gl.Program.createVF( + opts.vertex_fn, + opts.fragment_fn, + ); + errdefer program.destroy(); + + const pbind = try program.use(); + defer pbind.unbind(); + + const fbo = try gl.Framebuffer.create(); + errdefer fbo.destroy(); + const fbobind = try fbo.bind(.framebuffer); + defer fbobind.unbind(); + + const vao = try gl.VertexArray.create(); + errdefer vao.destroy(); + const vaobind = try vao.bind(); + defer vaobind.unbind(); + + if (VertexAttributes) |VA| try autoAttribute(VA, vaobind, opts.step_fn); + + return .{ + .program = program, + .fbo = fbo, + .vao = vao, + .stride = if (VertexAttributes) |VA| @sizeOf(VA) else 0, + .blending_enabled = opts.blending_enabled, + }; +} + +pub fn deinit(self: *const Self) void { + self.program.destroy(); +} + +fn autoAttribute( + T: type, + vaobind: gl.VertexArray.Binding, + step_fn: Options.StepFunction, +) !void { + const divisor: gl.c.GLuint = switch (step_fn) { + .per_vertex => 0, + .per_instance => 1, + .constant => std.math.maxInt(gl.c.GLuint), + }; + + inline for (@typeInfo(T).@"struct".fields, 0..) |field, i| { + try vaobind.enableAttribArray(i); + try vaobind.attributeBinding(i, 0); + try vaobind.bindingDivisor(i, divisor); + + const offset = @offsetOf(T, field.name); + + const FT = switch (@typeInfo(field.type)) { + .@"struct" => |s| s.backing_integer.?, + .@"enum" => |e| e.tag_type, + else => field.type, + }; + + const size, const IT = switch (@typeInfo(FT)) { + .array => |a| .{ a.len, a.child }, + else => .{ 1, FT }, + }; + + try switch (IT) { + u8 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_UNSIGNED_BYTE, + offset, + ), + u16 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_UNSIGNED_SHORT, + offset, + ), + u32 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_UNSIGNED_INT, + offset, + ), + i8 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_BYTE, + offset, + ), + i16 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_SHORT, + offset, + ), + i32 => vaobind.attributeIFormat( + i, + size, + gl.c.GL_INT, + offset, + ), + f16 => vaobind.attributeFormat( + i, + size, + gl.c.GL_HALF_FLOAT, + false, + offset, + ), + f32 => vaobind.attributeFormat( + i, + size, + gl.c.GL_FLOAT, + false, + offset, + ), + f64 => vaobind.attributeLFormat( + i, + size, + offset, + ), + else => unreachable, + }; + } +} diff --git a/vendor/libghostty-vt/src/renderer/opengl/RenderPass.zig b/vendor/libghostty-vt/src/renderer/opengl/RenderPass.zig new file mode 100644 index 00000000..18066494 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/opengl/RenderPass.zig @@ -0,0 +1,144 @@ +//! Wrapper for handling render passes. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const gl = @import("opengl"); + +const Sampler = @import("Sampler.zig"); +const Target = @import("Target.zig"); +const Texture = @import("Texture.zig"); +const Pipeline = @import("Pipeline.zig"); +const Buffer = @import("buffer.zig").Buffer; + +/// Options for beginning a render pass. +pub const Options = struct { + /// Color attachments for this render pass. + attachments: []const Attachment, + + /// Describes a color attachment. + pub const Attachment = struct { + target: union(enum) { + texture: Texture, + target: Target, + }, + clear_color: ?[4]f32 = null, + }; +}; + +/// Describes a step in a render pass. +pub const Step = struct { + pipeline: Pipeline, + uniforms: ?gl.Buffer = null, + buffers: []const ?gl.Buffer = &.{}, + textures: []const ?Texture = &.{}, + samplers: []const ?Sampler = &.{}, + draw: Draw, + + /// Describes the draw call for this step. + pub const Draw = struct { + type: gl.Primitive, + vertex_count: usize, + instance_count: usize = 1, + }; +}; + +attachments: []const Options.Attachment, + +step_number: usize = 0, + +/// Begin a render pass. +pub fn begin( + opts: Options, +) Self { + return .{ + .attachments = opts.attachments, + }; +} + +/// Add a step to this render pass. +/// +/// TODO: Errors are silently ignored in this function, maybe they shouldn't be? +pub fn step(self: *Self, s: Step) void { + if (s.draw.instance_count == 0) return; + + const pbind = s.pipeline.program.use() catch return; + defer pbind.unbind(); + + const vaobind = s.pipeline.vao.bind() catch return; + defer vaobind.unbind(); + + const fbobind = switch (self.attachments[0].target) { + .target => |t| t.framebuffer.bind(.framebuffer) catch return, + .texture => |t| bind: { + const fbobind = s.pipeline.fbo.bind(.framebuffer) catch return; + fbobind.texture2D(.color0, t.target, t.texture, 0) catch { + fbobind.unbind(); + return; + }; + break :bind fbobind; + }, + }; + defer fbobind.unbind(); + + defer self.step_number += 1; + + // If we have a clear color and this is the + // first step in the pass, go ahead and clear. + if (self.step_number == 0) if (self.attachments[0].clear_color) |c| { + gl.clearColor(c[0], c[1], c[2], c[3]); + gl.clear(gl.c.GL_COLOR_BUFFER_BIT); + }; + + // Bind the uniform buffer we bind at index 1 to align with Metal. + if (s.uniforms) |ubo| { + _ = ubo.bindBase(.uniform, 1) catch return; + } + + // Bind relevant texture units. + for (s.textures, 0..) |t, i| if (t) |tex| { + gl.Texture.active(@intCast(i)) catch return; + _ = tex.texture.bind(tex.target) catch return; + }; + + // Bind relevant samplers. + for (s.samplers, 0..) |s_, i| if (s_) |sampler| { + _ = sampler.sampler.bind(@intCast(i)) catch return; + }; + + // Bind 0th buffer as the vertex buffer, + // and bind the rest as storage buffers. + if (s.buffers.len > 0) { + if (s.buffers[0]) |vbo| vaobind.bindVertexBuffer( + 0, + vbo.id, + 0, + @intCast(s.pipeline.stride), + ) catch return; + + for (s.buffers[1..], 1..) |b, i| if (b) |buf| { + _ = buf.bindBase(.storage, @intCast(i)) catch return; + }; + } + + if (s.pipeline.blending_enabled) { + gl.enable(gl.c.GL_BLEND) catch return; + gl.blendFunc(gl.c.GL_ONE, gl.c.GL_ONE_MINUS_SRC_ALPHA) catch return; + } else { + gl.disable(gl.c.GL_BLEND) catch return; + } + + gl.drawArraysInstanced( + s.draw.type, + 0, + @intCast(s.draw.vertex_count), + @intCast(s.draw.instance_count), + ) catch return; +} + +/// Complete this render pass. +/// This struct can no longer be used after calling this. +pub fn complete(self: *const Self) void { + _ = self; + gl.flush(); +} diff --git a/vendor/libghostty-vt/src/renderer/opengl/Sampler.zig b/vendor/libghostty-vt/src/renderer/opengl/Sampler.zig new file mode 100644 index 00000000..f4013c68 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/opengl/Sampler.zig @@ -0,0 +1,45 @@ +//! Wrapper for handling samplers. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const gl = @import("opengl"); + +const OpenGL = @import("../OpenGL.zig"); + +const log = std.log.scoped(.opengl); + +/// Options for initializing a sampler. +pub const Options = struct { + min_filter: gl.Texture.MinFilter, + mag_filter: gl.Texture.MagFilter, + wrap_s: gl.Texture.Wrap, + wrap_t: gl.Texture.Wrap, +}; + +sampler: gl.Sampler, + +pub const Error = error{ + /// An OpenGL API call failed. + OpenGLFailed, +}; + +/// Initialize a sampler +pub fn init( + opts: Options, +) Error!Self { + const sampler = gl.Sampler.create() catch return error.OpenGLFailed; + errdefer sampler.destroy(); + sampler.parameter(.WrapS, @intFromEnum(opts.wrap_s)) catch return error.OpenGLFailed; + sampler.parameter(.WrapT, @intFromEnum(opts.wrap_t)) catch return error.OpenGLFailed; + sampler.parameter(.MinFilter, @intFromEnum(opts.min_filter)) catch return error.OpenGLFailed; + sampler.parameter(.MagFilter, @intFromEnum(opts.mag_filter)) catch return error.OpenGLFailed; + + return .{ + .sampler = sampler, + }; +} + +pub fn deinit(self: Self) void { + self.sampler.destroy(); +} diff --git a/vendor/libghostty-vt/src/renderer/opengl/Target.zig b/vendor/libghostty-vt/src/renderer/opengl/Target.zig new file mode 100644 index 00000000..5c6d818f --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/opengl/Target.zig @@ -0,0 +1,60 @@ +//! Represents a render target. +//! +//! In this case, an OpenGL renderbuffer-backed framebuffer. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const gl = @import("opengl"); + +const log = std.log.scoped(.opengl); + +/// Options for initializing a Target +pub const Options = struct { + /// Desired width + width: usize, + /// Desired height + height: usize, + + /// Internal format for the renderbuffer. + internal_format: gl.Texture.InternalFormat, +}; + +/// The underlying `gl.Framebuffer` instance. +framebuffer: gl.Framebuffer, + +/// The underlying `gl.Renderbuffer` instance. +renderbuffer: gl.Renderbuffer, + +/// Current width of this target. +width: usize, +/// Current height of this target. +height: usize, + +pub fn init(opts: Options) !Self { + const rbo = try gl.Renderbuffer.create(); + const bound_rbo = try rbo.bind(); + defer bound_rbo.unbind(); + try bound_rbo.storage( + opts.internal_format, + @intCast(opts.width), + @intCast(opts.height), + ); + + const fbo = try gl.Framebuffer.create(); + const bound_fbo = try fbo.bind(.framebuffer); + defer bound_fbo.unbind(); + try bound_fbo.renderbuffer(.color0, rbo); + + return .{ + .framebuffer = fbo, + .renderbuffer = rbo, + .width = opts.width, + .height = opts.height, + }; +} + +pub fn deinit(self: *Self) void { + self.framebuffer.destroy(); + self.renderbuffer.destroy(); +} diff --git a/vendor/libghostty-vt/src/renderer/opengl/Texture.zig b/vendor/libghostty-vt/src/renderer/opengl/Texture.zig new file mode 100644 index 00000000..c37ec686 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/opengl/Texture.zig @@ -0,0 +1,104 @@ +//! Wrapper for handling textures. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const gl = @import("opengl"); + +const OpenGL = @import("../OpenGL.zig"); + +const log = std.log.scoped(.opengl); + +/// Options for initializing a texture. +pub const Options = struct { + format: gl.Texture.Format, + internal_format: gl.Texture.InternalFormat, + target: gl.Texture.Target, + min_filter: gl.Texture.MinFilter, + mag_filter: gl.Texture.MagFilter, + wrap_s: gl.Texture.Wrap, + wrap_t: gl.Texture.Wrap, +}; + +texture: gl.Texture, + +/// The width of this texture. +width: usize, +/// The height of this texture. +height: usize, + +/// Format for this texture. +format: gl.Texture.Format, + +/// Target for this texture. +target: gl.Texture.Target, + +pub const Error = error{ + /// An OpenGL API call failed. + OpenGLFailed, +}; + +/// Initialize a texture +pub fn init( + opts: Options, + width: usize, + height: usize, + data: ?[]const u8, +) Error!Self { + const tex = gl.Texture.create() catch return error.OpenGLFailed; + errdefer tex.destroy(); + { + const texbind = tex.bind(opts.target) catch return error.OpenGLFailed; + defer texbind.unbind(); + texbind.parameter(.WrapS, @intFromEnum(opts.wrap_s)) catch return error.OpenGLFailed; + texbind.parameter(.WrapT, @intFromEnum(opts.wrap_t)) catch return error.OpenGLFailed; + texbind.parameter(.MinFilter, @intFromEnum(opts.min_filter)) catch return error.OpenGLFailed; + texbind.parameter(.MagFilter, @intFromEnum(opts.mag_filter)) catch return error.OpenGLFailed; + texbind.image2D( + 0, + opts.internal_format, + @intCast(width), + @intCast(height), + opts.format, + .UnsignedByte, + if (data) |d| @ptrCast(d.ptr) else null, + ) catch return error.OpenGLFailed; + } + + return .{ + .texture = tex, + .width = width, + .height = height, + .format = opts.format, + .target = opts.target, + }; +} + +pub fn deinit(self: Self) void { + self.texture.destroy(); +} + +/// Replace a region of the texture with the provided data. +/// +/// Does NOT check the dimensions of the data to ensure correctness. +pub fn replaceRegion( + self: Self, + x: usize, + y: usize, + width: usize, + height: usize, + data: []const u8, +) Error!void { + const texbind = self.texture.bind(self.target) catch return error.OpenGLFailed; + defer texbind.unbind(); + texbind.subImage2D( + 0, + @intCast(x), + @intCast(y), + @intCast(width), + @intCast(height), + self.format, + .UnsignedByte, + data.ptr, + ) catch return error.OpenGLFailed; +} diff --git a/vendor/libghostty-vt/src/renderer/opengl/buffer.zig b/vendor/libghostty-vt/src/renderer/opengl/buffer.zig new file mode 100644 index 00000000..f9cbbceb --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/opengl/buffer.zig @@ -0,0 +1,126 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const gl = @import("opengl"); + +const OpenGL = @import("../OpenGL.zig"); + +const log = std.log.scoped(.opengl); + +/// Options for initializing a buffer. +pub const Options = struct { + target: gl.Buffer.Target = .array, + usage: gl.Buffer.Usage = .dynamic_draw, +}; + +/// OpenGL data storage for a certain set of equal types. This is usually +/// used for vertex buffers, etc. This helpful wrapper makes it easy to +/// prealloc, shrink, grow, sync, buffers with OpenGL. +pub fn Buffer(comptime T: type) type { + return struct { + const Self = @This(); + + /// Underlying `gl.Buffer` instance. + buffer: gl.Buffer, + + /// Options this buffer was allocated with. + opts: Options, + + /// Current allocated length of the data store. + /// Note this is the number of `T`s, not the size in bytes. + len: usize, + + /// Initialize a buffer with the given length pre-allocated. + pub fn init(opts: Options, len: usize) !Self { + const buffer = try gl.Buffer.create(); + errdefer buffer.destroy(); + + const binding = try buffer.bind(opts.target); + defer binding.unbind(); + + try binding.setDataNullManual(len * @sizeOf(T), opts.usage); + + return .{ + .buffer = buffer, + .opts = opts, + .len = len, + }; + } + + /// Init the buffer filled with the given data. + pub fn initFill(opts: Options, data: []const T) !Self { + const buffer = try gl.Buffer.create(); + errdefer buffer.destroy(); + + const binding = try buffer.bind(opts.target); + defer binding.unbind(); + + try binding.setData(data, opts.usage); + + return .{ + .buffer = buffer, + .opts = opts, + .len = data.len * @sizeOf(T), + }; + } + + pub fn deinit(self: Self) void { + self.buffer.destroy(); + } + + /// Sync new contents to the buffer. The data is expected to be the + /// complete contents of the buffer. If the amount of data is larger + /// than the buffer length, the buffer will be reallocated. + /// + /// If the amount of data is smaller than the buffer length, the + /// remaining data in the buffer is left untouched. + pub fn sync(self: *Self, data: []const T) !void { + const binding = try self.buffer.bind(self.opts.target); + defer binding.unbind(); + + // If we need more space than our buffer has, we need to reallocate. + if (data.len > self.len) { + // Reallocate the buffer to hold double what we require. + self.len = data.len * 2; + try binding.setDataNullManual( + self.len * @sizeOf(T), + self.opts.usage, + ); + } + + // We can fit within the buffer so we can just replace bytes. + try binding.setSubData(0, data); + } + + /// Like Buffer.sync but takes data from an array of ArrayLists, + /// rather than a single array. Returns the number of items synced. + pub fn syncFromArrayLists(self: *Self, lists: []const std.ArrayListUnmanaged(T)) !usize { + const binding = try self.buffer.bind(self.opts.target); + defer binding.unbind(); + + var total_len: usize = 0; + for (lists) |list| { + total_len += list.items.len; + } + + // If we need more space than our buffer has, we need to reallocate. + if (total_len > self.len) { + // Reallocate the buffer to hold double what we require. + self.len = total_len * 2; + try binding.setDataNullManual( + self.len * @sizeOf(T), + self.opts.usage, + ); + } + + // We can fit within the buffer so we can just replace bytes. + var i: usize = 0; + + for (lists) |list| { + try binding.setSubData(i, list.items); + i += list.items.len * @sizeOf(T); + } + + return total_len; + } + }; +} diff --git a/vendor/libghostty-vt/src/renderer/opengl/shaders.zig b/vendor/libghostty-vt/src/renderer/opengl/shaders.zig new file mode 100644 index 00000000..68c1f36a --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/opengl/shaders.zig @@ -0,0 +1,376 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = @import("../../quirks.zig").inlineAssert; +const math = @import("../../math.zig"); + +const Pipeline = @import("Pipeline.zig"); + +const log = std.log.scoped(.opengl); + +const pipeline_descs: []const struct { [:0]const u8, PipelineDescription } = + &.{ + .{ "bg_color", .{ + .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/bg_color.f.glsl"), + .blending_enabled = false, + } }, + .{ "cell_bg", .{ + .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/cell_bg.f.glsl"), + .blending_enabled = true, + } }, + .{ "cell_text", .{ + .vertex_attributes = CellText, + .vertex_fn = loadShaderCode("../shaders/glsl/cell_text.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/cell_text.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "image", .{ + .vertex_attributes = Image, + .vertex_fn = loadShaderCode("../shaders/glsl/image.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/image.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, + .{ "bg_image", .{ + .vertex_attributes = BgImage, + .vertex_fn = loadShaderCode("../shaders/glsl/bg_image.v.glsl"), + .fragment_fn = loadShaderCode("../shaders/glsl/bg_image.f.glsl"), + .step_fn = .per_instance, + .blending_enabled = true, + } }, + }; + +/// All the comptime-known info about a pipeline, so that +/// we can define them ahead-of-time in an ergonomic way. +const PipelineDescription = struct { + vertex_attributes: ?type = null, + vertex_fn: [:0]const u8, + fragment_fn: [:0]const u8, + step_fn: Pipeline.Options.StepFunction = .per_vertex, + blending_enabled: bool = true, + + fn initPipeline(self: PipelineDescription) !Pipeline { + return try .init(self.vertex_attributes, .{ + .vertex_fn = self.vertex_fn, + .fragment_fn = self.fragment_fn, + .step_fn = self.step_fn, + .blending_enabled = self.blending_enabled, + }); + } +}; + +/// We create a type for the pipeline collection based on our desc array. +const PipelineCollection = t: { + var fields: [pipeline_descs.len]std.builtin.Type.StructField = undefined; + for (pipeline_descs, 0..) |pipeline, i| { + fields[i] = .{ + .name = pipeline[0], + .type = Pipeline, + .default_value_ptr = null, + .is_comptime = false, + .alignment = @alignOf(Pipeline), + }; + } + break :t @Type(.{ .@"struct" = .{ + .layout = .auto, + .fields = &fields, + .decls = &.{}, + .is_tuple = false, + } }); +}; + +/// This contains the state for the shaders used by the Metal renderer. +pub const Shaders = struct { + /// Collection of available render pipelines. + pipelines: PipelineCollection, + + /// Custom shaders to run against the final drawable texture. This + /// can be used to apply a lot of effects. Each shader is run in sequence + /// against the output of the previous shader. + post_pipelines: []const Pipeline, + + /// Set to true when deinited, if you try to deinit a defunct set + /// of shaders it will just be ignored, to prevent double-free. + defunct: bool = false, + + /// Initialize our shader set. + /// + /// "post_shaders" is an optional list of postprocess shaders to run + /// against the final drawable texture. This is an array of shader source + /// code, not file paths. + pub fn init( + alloc: Allocator, + post_shaders: []const [:0]const u8, + ) !Shaders { + var pipelines: PipelineCollection = undefined; + + var initialized_pipelines: usize = 0; + + errdefer inline for (pipeline_descs, 0..) |pipeline, i| { + if (i < initialized_pipelines) { + @field(pipelines, pipeline[0]).deinit(); + } + }; + + inline for (pipeline_descs) |pipeline| { + @field(pipelines, pipeline[0]) = try pipeline[1].initPipeline(); + initialized_pipelines += 1; + } + + const post_pipelines: []const Pipeline = initPostPipelines( + alloc, + post_shaders, + ) catch |err| err: { + // If an error happens while building postprocess shaders we + // want to just not use any postprocess shaders since we don't + // want to block Ghostty from working. + log.warn("error initializing postprocess shaders err={}", .{err}); + break :err &.{}; + }; + errdefer if (post_pipelines.len > 0) { + for (post_pipelines) |pipeline| pipeline.deinit(); + alloc.free(post_pipelines); + }; + + return .{ + .pipelines = pipelines, + .post_pipelines = post_pipelines, + }; + } + + pub fn deinit(self: *Shaders, alloc: Allocator) void { + if (self.defunct) return; + self.defunct = true; + + // Release our primary shaders + inline for (pipeline_descs) |pipeline| { + @field(self.pipelines, pipeline[0]).deinit(); + } + + // Release our postprocess shaders + if (self.post_pipelines.len > 0) { + for (self.post_pipelines) |pipeline| { + pipeline.deinit(); + } + alloc.free(self.post_pipelines); + } + } +}; + +/// The uniforms that are passed to our shaders. +pub const Uniforms = extern struct { + /// The projection matrix for turning world coordinates to normalized. + /// This is calculated based on the size of the screen. + projection_matrix: math.Mat align(16), + + /// Size of the screen (render target) in pixels. + screen_size: [2]f32 align(8), + + /// Size of a single cell in pixels, unscaled. + cell_size: [2]f32 align(8), + + /// Size of the grid in columns and rows. + grid_size: [2]u16 align(4), + + /// The padding around the terminal grid in pixels. In order: + /// top, right, bottom, left. + grid_padding: [4]f32 align(16), + + /// Bit mask defining which directions to + /// extend cell colors in to the padding. + /// Order, LSB first: left, right, up, down + padding_extend: PaddingExtend align(4), + + /// The minimum contrast ratio for text. The contrast ratio is calculated + /// according to the WCAG 2.0 spec. + min_contrast: f32 align(4), + + /// The cursor position and color. + cursor_pos: [2]u16 align(4), + cursor_color: [4]u8 align(4), + + /// The background color for the whole surface. + bg_color: [4]u8 align(4), + + /// Various booleans, in a packed struct for space efficiency. + bools: Bools align(4), + + const Bools = packed struct(u32) { + /// Whether the cursor is 2 cells wide. + cursor_wide: bool, + + /// Indicates that colors provided to the shader are already in + /// the P3 color space, so they don't need to be converted from + /// sRGB. + use_display_p3: bool, + + /// Indicates that the color attachments for the shaders have + /// an `*_srgb` pixel format, which means the shaders need to + /// output linear RGB colors rather than gamma encoded colors, + /// since blending will be performed in linear space and then + /// Metal itself will re-encode the colors for storage. + use_linear_blending: bool, + + /// Enables a weight correction step that makes text rendered + /// with linear alpha blending have a similar apparent weight + /// (thickness) to gamma-incorrect blending. + use_linear_correction: bool = false, + + _padding: u28 = 0, + }; + + const PaddingExtend = packed struct(u32) { + left: bool = false, + right: bool = false, + up: bool = false, + down: bool = false, + _padding: u28 = 0, + }; +}; + +/// This is a single parameter for the terminal cell shader. +pub const CellText = extern struct { + glyph_pos: [2]u32 align(8) = .{ 0, 0 }, + glyph_size: [2]u32 align(8) = .{ 0, 0 }, + bearings: [2]i16 align(4) = .{ 0, 0 }, + grid_pos: [2]u16 align(4), + color: [4]u8 align(4), + atlas: Atlas align(1), + bools: packed struct(u8) { + no_min_contrast: bool = false, + is_cursor_glyph: bool = false, + _padding: u6 = 0, + } align(1) = .{}, + + pub const Atlas = enum(u8) { + grayscale = 0, + color = 1, + }; + + // test { + // // Minimizing the size of this struct is important, + // // so we test it in order to be aware of any changes. + // try std.testing.expectEqual(32, @sizeOf(CellText)); + // } +}; + +/// This is a single parameter for the cell bg shader. +pub const CellBg = [4]u8; + +/// Single parameter for the image shader. See shader for field details. +pub const Image = extern struct { + grid_pos: [2]f32 align(8), + cell_offset: [2]f32 align(8), + source_rect: [4]f32 align(16), + dest_size: [2]f32 align(8), +}; + +/// Single parameter for the bg image shader. +pub const BgImage = extern struct { + opacity: f32 align(4), + info: Info align(1), + + pub const Info = packed struct(u8) { + position: Position, + fit: Fit, + repeat: bool, + _padding: u1 = 0, + + pub const Position = enum(u4) { + tl = 0, + tc = 1, + tr = 2, + ml = 3, + mc = 4, + mr = 5, + bl = 6, + bc = 7, + br = 8, + }; + + pub const Fit = enum(u2) { + contain = 0, + cover = 1, + stretch = 2, + none = 3, + }; + }; +}; + +/// Initialize our custom shader pipelines. The shaders argument is a +/// set of shader source code, not file paths. +fn initPostPipelines( + alloc: Allocator, + shaders: []const [:0]const u8, +) ![]const Pipeline { + // If we have no shaders, do nothing. + if (shaders.len == 0) return &.{}; + + // Keeps track of how many shaders we successfully wrote. + var i: usize = 0; + + // Initialize our result set. If any error happens, we undo everything. + var pipelines = try alloc.alloc(Pipeline, shaders.len); + errdefer { + for (pipelines[0..i]) |pipeline| { + pipeline.deinit(); + } + alloc.free(pipelines); + } + + // Build each shader. Note we don't use "0.." to build our index + // because we need to keep track of our length to clean up above. + for (shaders) |source| { + pipelines[i] = try initPostPipeline(source); + i += 1; + } + + return pipelines; +} + +/// Initialize a single custom shader pipeline from shader source. +fn initPostPipeline(data: [:0]const u8) !Pipeline { + return try Pipeline.init(null, .{ + .vertex_fn = loadShaderCode("../shaders/glsl/full_screen.v.glsl"), + .fragment_fn = data, + }); +} + +/// Load shader code from the target path, processing `#include` directives. +/// +/// Comptime only for now, this code is really sloppy and makes a bunch of +/// assumptions about things being well formed and file names not containing +/// quote marks. If we ever want to process `#include`s for custom shaders +/// then we need to write something better than this for it. +fn loadShaderCode(comptime path: []const u8) [:0]const u8 { + return comptime processIncludes(@embedFile(path), std.fs.path.dirname(path).?); +} + +/// Used by loadShaderCode +fn processIncludes(contents: [:0]const u8, basedir: []const u8) [:0]const u8 { + @setEvalBranchQuota(100_000); + var i: usize = 0; + while (i < contents.len) { + if (std.mem.startsWith(u8, contents[i..], "#include")) { + assert(std.mem.startsWith(u8, contents[i..], "#include \"")); + const start = i + "#include \"".len; + const end = std.mem.indexOfScalarPos(u8, contents, start, '"').?; + return std.fmt.comptimePrint( + "{s}{s}{s}", + .{ + contents[0..i], + @embedFile(basedir ++ "/" ++ contents[start..end]), + processIncludes(contents[end + 1 ..], basedir), + }, + ); + } + if (std.mem.indexOfPos(u8, contents, i, "\n#")) |j| { + i = (j + 1); + } else { + break; + } + } + return contents; +} diff --git a/vendor/libghostty-vt/src/renderer/row.zig b/vendor/libghostty-vt/src/renderer/row.zig new file mode 100644 index 00000000..74a64101 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/row.zig @@ -0,0 +1,63 @@ +const terminal = @import("../terminal/main.zig"); + +// TODO: Test neverExtendBg function + +/// Returns true if the row of this pin should never have its background +/// color extended for filling padding space in the renderer. This is +/// a set of heuristics that help making our padding look better. +pub fn neverExtendBg( + row: terminal.page.Row, + cells: []const terminal.page.Cell, + styles: []const terminal.Style, + palette: *const terminal.color.Palette, + default_background: terminal.color.RGB, +) bool { + // Any semantic prompts should not have their background extended + // because prompts often contain special formatting (such as + // powerline) that looks bad when extended. + switch (row.semantic_prompt) { + .prompt, .prompt_continuation => return true, + .none => {}, + } + + for (0.., cells) |x, *cell| { + // If any cell has a default background color then we don't + // extend because the default background color probably looks + // good enough as an extension. + switch (cell.content_tag) { + // If it is a background color cell, we check the color. + .bg_color_palette, .bg_color_rgb => { + const s: terminal.Style = if (cell.hasStyling()) styles[x] else .{}; + const bg = s.bg(cell, palette) orelse return true; + if (bg.eql(default_background)) return true; + }, + + // If its a codepoint cell we can check the style. + .codepoint, .codepoint_grapheme => { + // For codepoint containing, we also never extend bg + // if any cell has a powerline glyph because these are + // perfect-fit. + switch (cell.codepoint()) { + // Powerline + 0xE0B0...0xE0C8, + 0xE0CA, + 0xE0CC...0xE0D2, + 0xE0D4, + => return true, + + else => {}, + } + + // Never extend a cell that has a default background. + // A default background is applied if there is no background + // on the style or the explicitly set background + // matches our default background. + const s: terminal.Style = if (cell.hasStyling()) styles[x] else .{}; + const bg = s.bg(cell, palette) orelse return true; + if (bg.eql(default_background)) return true; + }, + } + } + + return false; +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/glsl/bg_color.f.glsl b/vendor/libghostty-vt/src/renderer/shaders/glsl/bg_color.f.glsl new file mode 100644 index 00000000..616c44b8 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/glsl/bg_color.f.glsl @@ -0,0 +1,13 @@ +#include "common.glsl" + +// Must declare this output for some versions of OpenGL. +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + out_FragColor = load_color( + unpack4u8(bg_color_packed_4u8), + use_linear_blending + ); +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/glsl/bg_image.f.glsl b/vendor/libghostty-vt/src/renderer/shaders/glsl/bg_image.f.glsl new file mode 100644 index 00000000..ee1195ef --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/glsl/bg_image.f.glsl @@ -0,0 +1,63 @@ +#include "common.glsl" + +// Position the FragCoord origin to the upper left +// so as to align with our texture's directionality. +layout(origin_upper_left) in vec4 gl_FragCoord; + +layout(binding = 0) uniform sampler2D image; + +flat in vec4 bg_color; +flat in vec2 offset; +flat in vec2 scale; +flat in float opacity; +flat in uint repeat; + +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + // Our texture coordinate is based on the screen position, offset by the + // dest rect origin, and scaled by the ratio between the dest rect size + // and the original texture size, which effectively scales the original + // size of the texture to the dest rect size. + vec2 tex_coord = (gl_FragCoord.xy - offset) * scale; + + vec2 tex_size = textureSize(image, 0); + + // If we need to repeat the texture, wrap the coordinates. + if (repeat != 0) { + tex_coord = mod(mod(tex_coord, tex_size) + tex_size, tex_size); + } + + vec4 rgba; + // If we're out of bounds, we have no color, + // otherwise we sample the texture for it. + if (any(lessThan(tex_coord, vec2(0.0))) || + any(greaterThan(tex_coord, tex_size))) + { + rgba = vec4(0.0); + } else { + // We divide by the texture size to normalize for sampling. + rgba = texture(image, tex_coord / tex_size); + + if (!use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= rgba.a; + } + + // Multiply it by the configured opacity, but cap it at + // the value that will make it fully opaque relative to + // the background color alpha, so it isn't overexposed. + rgba *= min(opacity, 1.0 / bg_color.a); + + // Blend it on to a fully opaque version of the background color. + rgba += max(vec4(0.0), vec4(bg_color.rgb, 1.0) * vec4(1.0 - rgba.a)); + + // Multiply everything by the background color alpha. + rgba *= bg_color.a; + + out_FragColor = rgba; +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/glsl/bg_image.v.glsl b/vendor/libghostty-vt/src/renderer/shaders/glsl/bg_image.v.glsl new file mode 100644 index 00000000..d55aa174 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/glsl/bg_image.v.glsl @@ -0,0 +1,145 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2D image; + +layout(location = 0) in float in_opacity; +layout(location = 1) in uint info; + +// 4 bits of info. +const uint BG_IMAGE_POSITION = 15u; +const uint BG_IMAGE_TL = 0u; +const uint BG_IMAGE_TC = 1u; +const uint BG_IMAGE_TR = 2u; +const uint BG_IMAGE_ML = 3u; +const uint BG_IMAGE_MC = 4u; +const uint BG_IMAGE_MR = 5u; +const uint BG_IMAGE_BL = 6u; +const uint BG_IMAGE_BC = 7u; +const uint BG_IMAGE_BR = 8u; + +// 2 bits of info shifted 4. +const uint BG_IMAGE_FIT = 3u << 4; +const uint BG_IMAGE_CONTAIN = 0u << 4; +const uint BG_IMAGE_COVER = 1u << 4; +const uint BG_IMAGE_STRETCH = 2u << 4; +const uint BG_IMAGE_NO_FIT = 3u << 4; + +// 1 bit of info shifted 6. +const uint BG_IMAGE_REPEAT = 1u << 6; + +flat out vec4 bg_color; +flat out vec2 offset; +flat out vec2 scale; +flat out float opacity; +// We use a uint to pass the repeat value because +// bools aren't allowed for vertex outputs in OpenGL. +flat out uint repeat; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + vec4 position; + position.x = (gl_VertexID == 2) ? 3.0 : -1.0; + position.y = (gl_VertexID == 0) ? -3.0 : 1.0; + position.z = 1.0; + position.w = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + gl_Position = position; + + opacity = in_opacity; + + repeat = info & BG_IMAGE_REPEAT; + + vec2 screen_size = screen_size; + vec2 tex_size = textureSize(image, 0); + + vec2 dest_size = tex_size; + switch (info & BG_IMAGE_FIT) { + // For `contain` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is smaller. + case BG_IMAGE_CONTAIN: { + float scale = min(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `cover` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is larger. + case BG_IMAGE_COVER: { + float scale = max(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `stretch` we stretch the image to the size of + // the screen without worrying about aspect ratio. + case BG_IMAGE_STRETCH: { + dest_size = screen_size; + } break; + + // For `none` we just use the original texture size. + case BG_IMAGE_NO_FIT: { + dest_size = tex_size; + } break; + } + + vec2 start = vec2(0.0); + vec2 mid = (screen_size - dest_size) / vec2(2.0); + vec2 end = screen_size - dest_size; + + vec2 dest_offset = mid; + switch (info & BG_IMAGE_POSITION) { + case BG_IMAGE_TL: { + dest_offset = vec2(start.x, start.y); + } break; + case BG_IMAGE_TC: { + dest_offset = vec2(mid.x, start.y); + } break; + case BG_IMAGE_TR: { + dest_offset = vec2(end.x, start.y); + } break; + case BG_IMAGE_ML: { + dest_offset = vec2(start.x, mid.y); + } break; + case BG_IMAGE_MC: { + dest_offset = vec2(mid.x, mid.y); + } break; + case BG_IMAGE_MR: { + dest_offset = vec2(end.x, mid.y); + } break; + case BG_IMAGE_BL: { + dest_offset = vec2(start.x, end.y); + } break; + case BG_IMAGE_BC: { + dest_offset = vec2(mid.x, end.y); + } break; + case BG_IMAGE_BR: { + dest_offset = vec2(end.x, end.y); + } break; + } + + offset = dest_offset; + scale = tex_size / dest_size; + + // We load a fully opaque version of the bg color and combine it with + // the alpha separately, because we need these as separate values in + // the framgment shader. + uvec4 u_bg_color = unpack4u8(bg_color_packed_4u8); + bg_color = vec4(load_color( + uvec4(u_bg_color.rgb, 255), + use_linear_blending + ).rgb, float(u_bg_color.a) / 255.0); +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/glsl/cell_bg.f.glsl b/vendor/libghostty-vt/src/renderer/shaders/glsl/cell_bg.f.glsl new file mode 100644 index 00000000..fa48c673 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/glsl/cell_bg.f.glsl @@ -0,0 +1,61 @@ +#include "common.glsl" + +// Position the origin to the upper left +layout(origin_upper_left) in vec4 gl_FragCoord; + +// Must declare this output for some versions of OpenGL. +layout(location = 0) out vec4 out_FragColor; + +layout(binding = 1, std430) readonly buffer bg_cells { + uint cells[]; +}; + +vec4 cell_bg() { + uvec2 grid_size = unpack2u16(grid_size_packed_2u16); + ivec2 grid_pos = ivec2(floor((gl_FragCoord.xy - grid_padding.wx) / cell_size)); + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + vec4 bg = vec4(0.0); + + // Clamp x position, extends edge bg colors in to padding on sides. + if (grid_pos.x < 0) { + if ((padding_extend & EXTEND_LEFT) != 0) { + grid_pos.x = 0; + } else { + return bg; + } + } else if (grid_pos.x > grid_size.x - 1) { + if ((padding_extend & EXTEND_RIGHT) != 0) { + grid_pos.x = int(grid_size.x) - 1; + } else { + return bg; + } + } + + // Clamp y position if we should extend, otherwise discard if out of bounds. + if (grid_pos.y < 0) { + if ((padding_extend & EXTEND_UP) != 0) { + grid_pos.y = 0; + } else { + return bg; + } + } else if (grid_pos.y > grid_size.y - 1) { + if ((padding_extend & EXTEND_DOWN) != 0) { + grid_pos.y = int(grid_size.y) - 1; + } else { + return bg; + } + } + + // Load the color for the cell. + vec4 cell_color = load_color( + unpack4u8(cells[grid_pos.y * grid_size.x + grid_pos.x]), + use_linear_blending + ); + + return cell_color; +} + +void main() { + out_FragColor = cell_bg(); +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/glsl/cell_text.f.glsl b/vendor/libghostty-vt/src/renderer/shaders/glsl/cell_text.f.glsl new file mode 100644 index 00000000..176efcbd --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/glsl/cell_text.f.glsl @@ -0,0 +1,100 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2DRect atlas_grayscale; +layout(binding = 1) uniform sampler2DRect atlas_color; + +in CellTextVertexOut { + flat uint atlas; + flat vec4 color; + flat vec4 bg_color; + vec2 tex_coord; +} in_data; + +// Values `atlas` can take. +const uint ATLAS_GRAYSCALE = 0u; +const uint ATLAS_COLOR = 1u; + +// Must declare this output for some versions of OpenGL. +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + bool use_linear_correction = (bools & USE_LINEAR_CORRECTION) != 0; + + switch (in_data.atlas) { + default: + case ATLAS_GRAYSCALE: + { + // Our input color is always linear. + vec4 color = in_data.color; + + // If we're not doing linear blending, then we need to + // re-apply the gamma encoding to our color manually. + // + // Since the alpha is premultiplied, we need to divide + // it out before unlinearizing and re-multiply it after. + if (!use_linear_blending) { + color.rgb /= vec3(color.a); + color = unlinearize(color); + color.rgb *= vec3(color.a); + } + + // Fetch our alpha mask for this pixel. + float a = texture(atlas_grayscale, in_data.tex_coord).r; + + // Linear blending weight correction corrects the alpha value to + // produce blending results which match gamma-incorrect blending. + if (use_linear_correction) { + // Short explanation of how this works: + // + // We get the luminances of the foreground and background colors, + // and then unlinearize them and perform blending on them. This + // gives us our desired luminance, which we derive our new alpha + // value from by mapping the range [bg_l, fg_l] to [0, 1], since + // our final blend will be a linear interpolation from bg to fg. + // + // This yields virtually identical results for grayscale blending, + // and very similar but non-identical results for color blending. + vec4 bg = in_data.bg_color; + float fg_l = luminance(color.rgb); + float bg_l = luminance(bg.rgb); + // To avoid numbers going haywire, we don't apply correction + // when the bg and fg luminances are within 0.001 of each other. + if (abs(fg_l - bg_l) > 0.001) { + float blend_l = linearize(unlinearize(fg_l) * a + unlinearize(bg_l) * (1.0 - a)); + a = clamp((blend_l - bg_l) / (fg_l - bg_l), 0.0, 1.0); + } + } + + // Multiply our whole color by the alpha mask. + // Since we use premultiplied alpha, this is + // the correct way to apply the mask. + color *= a; + + out_FragColor = color; + return; + } + + case ATLAS_COLOR: + { + // For now, we assume that color glyphs + // are already premultiplied linear colors. + vec4 color = texture(atlas_color, in_data.tex_coord); + + // If we are doing linear blending, we can return this right away. + if (use_linear_blending) { + out_FragColor = color; + return; + } + + // Otherwise we need to unlinearize the color. Since the alpha is + // premultiplied, we need to divide it out before unlinearizing. + color.rgb /= vec3(color.a); + color = unlinearize(color); + color.rgb *= vec3(color.a); + + out_FragColor = color; + return; + } + } +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/glsl/cell_text.v.glsl b/vendor/libghostty-vt/src/renderer/shaders/glsl/cell_text.v.glsl new file mode 100644 index 00000000..7e38e2f0 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/glsl/cell_text.v.glsl @@ -0,0 +1,146 @@ +#include "common.glsl" + +// The position of the glyph in the texture (x, y) +layout(location = 0) in uvec2 glyph_pos; + +// The size of the glyph in the texture (w, h) +layout(location = 1) in uvec2 glyph_size; + +// The left and top bearings for the glyph (x, y) +layout(location = 2) in ivec2 bearings; + +// The grid coordinates (x, y) where x < columns and y < rows +layout(location = 3) in uvec2 grid_pos; + +// The color of the rendered text glyph. +layout(location = 4) in uvec4 color; + +// Which atlas this glyph is in. +layout(location = 5) in uint atlas; + +// Misc glyph properties. +layout(location = 6) in uint glyph_bools; + +// Values `atlas` can take. +const uint ATLAS_GRAYSCALE = 0u; +const uint ATLAS_COLOR = 1u; + +// Masks for the `glyph_bools` attribute +const uint NO_MIN_CONTRAST = 1u; +const uint IS_CURSOR_GLYPH = 2u; + +out CellTextVertexOut { + flat uint atlas; + flat vec4 color; + flat vec4 bg_color; + vec2 tex_coord; +} out_data; + +layout(binding = 1, std430) readonly buffer bg_cells { + uint bg_colors[]; +}; + +void main() { + uvec2 grid_size = unpack2u16(grid_size_packed_2u16); + uvec2 cursor_pos = unpack2u16(cursor_pos_packed_2u16); + bool cursor_wide = (bools & CURSOR_WIDE) != 0; + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + // Convert the grid x, y into world space x, y by accounting for cell size + vec2 cell_pos = cell_size * vec2(grid_pos); + + int vid = gl_VertexID; + + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. + // + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) + vec2 corner; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); + + out_data.atlas = atlas; + + // === Grid Cell === + // +X + // 0,0--...-> + // | + // . offset.x = bearings.x + // +Y. .|. + // . | | + // | cell_pos -> +-------+ _. + // v ._| |_. _|- offset.y = cell_size.y - bearings.y + // | | .###. | | + // | | #...# | | + // glyph_size.y -+ | ##### | | + // | | #.... | +- bearings.y + // |_| .#### | | + // | |_| + // +-------+ + // |_._| + // | + // glyph_size.x + // + // In order to get the top left of the glyph, we compute an offset based on + // the bearings. The Y bearing is the distance from the bottom of the cell + // to the top of the glyph, so we subtract it from the cell height to get + // the y offset. The X bearing is the distance from the left of the cell + // to the left of the glyph, so it works as the x offset directly. + + vec2 size = vec2(glyph_size); + vec2 offset = vec2(bearings); + + offset.y = cell_size.y - offset.y; + + // Calculate the final position of the cell which uses our glyph size + // and glyph offset to create the correct bounding box for the glyph. + cell_pos = cell_pos + size * corner + offset; + gl_Position = projection_matrix * vec4(cell_pos.x, cell_pos.y, 0.0f, 1.0f); + + // Calculate the texture coordinate in pixels. This is NOT normalized + // (between 0.0 and 1.0), and does not need to be, since the texture will + // be sampled with pixel coordinate mode. + out_data.tex_coord = vec2(glyph_pos) + vec2(glyph_size) * corner; + + // Get our color. We always fetch a linearized version to + // make it easier to handle minimum contrast calculations. + out_data.color = load_color(color, true); + // Get the BG color + out_data.bg_color = load_color( + unpack4u8(bg_colors[grid_pos.y * grid_size.x + grid_pos.x]), + true + ); + // Blend it with the global bg color + vec4 global_bg = load_color( + unpack4u8(bg_color_packed_4u8), + true + ); + out_data.bg_color += global_bg * vec4(1.0 - out_data.bg_color.a); + + // If we have a minimum contrast, we need to check if we need to + // change the color of the text to ensure it has enough contrast + // with the background. + if (min_contrast > 1.0f && (glyph_bools & NO_MIN_CONTRAST) == 0) { + // Ensure our minimum contrast + out_data.color = contrasted_color(min_contrast, out_data.color, out_data.bg_color); + } + + // Check if current position is under cursor (including wide cursor) + bool is_cursor_pos = ((grid_pos.x == cursor_pos.x) || (cursor_wide && (grid_pos.x == (cursor_pos.x + 1)))) && (grid_pos.y == cursor_pos.y); + + // If this cell is the cursor cell, but we're not processing + // the cursor glyph itself, then we need to change the color. + if ((glyph_bools & IS_CURSOR_GLYPH) == 0 && is_cursor_pos) { + out_data.color = load_color(unpack4u8(cursor_color_packed_4u8), use_linear_blending); + } +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/glsl/common.glsl b/vendor/libghostty-vt/src/renderer/shaders/glsl/common.glsl new file mode 100644 index 00000000..1a7ea03a --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/glsl/common.glsl @@ -0,0 +1,156 @@ +#version 430 core + +// These are common definitions to be shared across shaders, the first +// line of any shader that needs these should be `#include "common.glsl"`. +// +// Included in this file are: +// - The interface block for the global uniforms. +// - Functions for unpacking values. +// - Functions for working with colors. + +//----------------------------------------------------------------------------// +// Global Uniforms +//----------------------------------------------------------------------------// +layout(binding = 1, std140) uniform Globals { + uniform mat4 projection_matrix; + uniform vec2 screen_size; + uniform vec2 cell_size; + uniform uint grid_size_packed_2u16; + uniform vec4 grid_padding; + uniform uint padding_extend; + uniform float min_contrast; + uniform uint cursor_pos_packed_2u16; + uniform uint cursor_color_packed_4u8; + uniform uint bg_color_packed_4u8; + uniform uint bools; +}; + +// Bools +const uint CURSOR_WIDE = 1u; +const uint USE_DISPLAY_P3 = 2u; +const uint USE_LINEAR_BLENDING = 4u; +const uint USE_LINEAR_CORRECTION = 8u; + +// Padding extend enum +const uint EXTEND_LEFT = 1u; +const uint EXTEND_RIGHT = 2u; +const uint EXTEND_UP = 4u; +const uint EXTEND_DOWN = 8u; + +//----------------------------------------------------------------------------// +// Functions for Unpacking Values +//----------------------------------------------------------------------------// +// NOTE: These unpack functions assume little-endian. +// If this ever becomes a problem... oh dear! + +uvec4 unpack4u8(uint packed_value) { + return uvec4( + uint(packed_value >> 0) & uint(0xFF), + uint(packed_value >> 8) & uint(0xFF), + uint(packed_value >> 16) & uint(0xFF), + uint(packed_value >> 24) & uint(0xFF) + ); +} + +uvec2 unpack2u16(uint packed_value) { + return uvec2( + uint(packed_value >> 0) & uint(0xFFFF), + uint(packed_value >> 16) & uint(0xFFFF) + ); +} + +ivec2 unpack2i16(int packed_value) { + return ivec2( + (packed_value << 16) >> 16, + (packed_value << 0) >> 16 + ); +} + +//----------------------------------------------------------------------------// +// Color Functions +//----------------------------------------------------------------------------// + +// Compute the luminance of the provided color. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float luminance(vec3 color) { + return dot(color, vec3(0.2126f, 0.7152f, 0.0722f)); +} + +// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float contrast_ratio(vec3 color1, vec3 color2) { + float luminance1 = luminance(color1) + 0.05; + float luminance2 = luminance(color2) + 0.05; + return max(luminance1, luminance2) / min(luminance1, luminance2); +} + +// Return the fg if the contrast ratio is greater than min, otherwise +// return a color that satisfies the contrast ratio. Currently, the color +// is always white or black, whichever has the highest contrast ratio. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +vec4 contrasted_color(float min_ratio, vec4 fg, vec4 bg) { + float ratio = contrast_ratio(fg.rgb, bg.rgb); + if (ratio < min_ratio) { + float white_ratio = contrast_ratio(vec3(1.0, 1.0, 1.0), bg.rgb); + float black_ratio = contrast_ratio(vec3(0.0, 0.0, 0.0), bg.rgb); + if (white_ratio > black_ratio) { + return vec4(1.0); + } else { + return vec4(0.0, 0.0, 0.0, 1.0); + } + } + + return fg; +} + +// Converts a color from sRGB gamma encoding to linear. +vec4 linearize(vec4 srgb) { + bvec3 cutoff = lessThanEqual(srgb.rgb, vec3(0.04045)); + vec3 higher = pow((srgb.rgb + vec3(0.055)) / vec3(1.055), vec3(2.4)); + vec3 lower = srgb.rgb / vec3(12.92); + + return vec4(mix(higher, lower, cutoff), srgb.a); +} +float linearize(float v) { + return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4); +} + +// Converts a color from linear to sRGB gamma encoding. +vec4 unlinearize(vec4 linear) { + bvec3 cutoff = lessThanEqual(linear.rgb, vec3(0.0031308)); + vec3 higher = pow(linear.rgb, vec3(1.0 / 2.4)) * vec3(1.055) - vec3(0.055); + vec3 lower = linear.rgb * vec3(12.92); + + return vec4(mix(higher, lower, cutoff), linear.a); +} +float unlinearize(float v) { + return v <= 0.0031308 ? v * 12.92 : pow(v, 1.0 / 2.4) * 1.055 - 0.055; +} + +// Load a 4 byte RGBA non-premultiplied color and linearize +// and convert it as necessary depending on the provided info. +// +// `linear` controls whether the returned color is linear or gamma encoded. +vec4 load_color( + uvec4 in_color, + bool linear +) { + // 0 .. 255 -> 0.0 .. 1.0 + vec4 color = vec4(in_color) / vec4(255.0f); + + // Linearize if necessary. + if (linear) color = linearize(color); + + // Premultiply our color by its alpha. + color.rgb *= color.a; + + return color; +} + +//----------------------------------------------------------------------------// diff --git a/vendor/libghostty-vt/src/renderer/shaders/glsl/full_screen.v.glsl b/vendor/libghostty-vt/src/renderer/shaders/glsl/full_screen.v.glsl new file mode 100644 index 00000000..b89cedfa --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/glsl/full_screen.v.glsl @@ -0,0 +1,24 @@ +#version 330 core + +void main() { + vec4 position; + position.x = (gl_VertexID == 2) ? 3.0 : -1.0; + position.y = (gl_VertexID == 0) ? -3.0 : 1.0; + position.z = 1.0; + position.w = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + gl_Position = position; +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/glsl/image.f.glsl b/vendor/libghostty-vt/src/renderer/shaders/glsl/image.f.glsl new file mode 100644 index 00000000..4f89d7a7 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/glsl/image.f.glsl @@ -0,0 +1,21 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2D image; + +in vec2 tex_coord; + +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + vec4 rgba = texture(image, tex_coord); + + if (!use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= vec3(rgba.a); + + out_FragColor = rgba; +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/glsl/image.v.glsl b/vendor/libghostty-vt/src/renderer/shaders/glsl/image.v.glsl new file mode 100644 index 00000000..779fae32 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/glsl/image.v.glsl @@ -0,0 +1,47 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2D image; + +layout(location = 0) in vec2 grid_pos; +layout(location = 1) in vec2 cell_offset; +layout(location = 2) in vec4 source_rect; +layout(location = 3) in vec2 dest_size; + +out vec2 tex_coord; + +void main() { + int vid = gl_VertexID; + + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. + // + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) + vec2 corner; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); + + // The texture coordinates start at our source x/y + // and add the width/height depending on the corner. + tex_coord = source_rect.xy; + tex_coord += source_rect.zw * corner; + + // Normalize the coordinates. + tex_coord /= textureSize(image, 0); + + // The position of our image starts at the top-left of the grid cell and + // adds the source rect width/height components. + vec2 image_pos = (cell_size * grid_pos) + cell_offset; + image_pos += dest_size * corner; + + gl_Position = projection_matrix * vec4(image_pos.xy, 1.0, 1.0); +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/shaders.metal b/vendor/libghostty-vt/src/renderer/shaders/shaders.metal new file mode 100644 index 00000000..4e02b633 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/shaders.metal @@ -0,0 +1,853 @@ +#include <metal_stdlib> + +using namespace metal; + +enum Padding : uint8_t { + EXTEND_LEFT = 1u, + EXTEND_RIGHT = 2u, + EXTEND_UP = 4u, + EXTEND_DOWN = 8u, +}; + +struct Uniforms { + float4x4 projection_matrix; + float2 screen_size; + float2 cell_size; + ushort2 grid_size; + float4 grid_padding; + uint8_t padding_extend; + float min_contrast; + ushort2 cursor_pos; + uchar4 cursor_color; + uchar4 bg_color; + bool cursor_wide; + bool use_display_p3; + bool use_linear_blending; + bool use_linear_correction; +}; + +//------------------------------------------------------------------- +// Color Functions +//------------------------------------------------------------------- +#pragma mark - Colors + +// D50-adapted sRGB to XYZ conversion matrix. +// http://www.brucelindbloom.com/Eqn_RGB_XYZ_Matrix.html +constant float3x3 sRGB_XYZ = transpose(float3x3( + 0.4360747, 0.3850649, 0.1430804, + 0.2225045, 0.7168786, 0.0606169, + 0.0139322, 0.0971045, 0.7141733 +)); +// XYZ to Display P3 conversion matrix. +// http://endavid.com/index.php?entry=79 +constant float3x3 XYZ_DP3 = transpose(float3x3( + 2.40414768,-0.99010704,-0.39759019, + -0.84239098, 1.79905954, 0.01597023, + 0.04838763,-0.09752546, 1.27393636 +)); +// By composing the two above matrices we get +// our sRGB to Display P3 conversion matrix. +constant float3x3 sRGB_DP3 = XYZ_DP3 * sRGB_XYZ; + +// Converts a color in linear sRGB to linear Display P3 +// +// TODO: The color matrix should probably be computed +// dynamically and passed as a uniform, rather +// than being hard coded above. +float3 srgb_to_display_p3(float3 srgb) { + return sRGB_DP3 * srgb; +} + +// Converts a color from sRGB gamma encoding to linear. +float4 linearize(float4 srgb) { + bool3 cutoff = srgb.rgb <= 0.04045; + float3 lower = srgb.rgb / 12.92; + float3 higher = pow((srgb.rgb + 0.055) / 1.055, 2.4); + srgb.rgb = mix(higher, lower, float3(cutoff)); + + return srgb; +} +float linearize(float v) { + return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4); +} + +// Converts a color from linear to sRGB gamma encoding. +float4 unlinearize(float4 linear) { + bool3 cutoff = linear.rgb <= 0.0031308; + float3 lower = linear.rgb * 12.92; + float3 higher = pow(linear.rgb, 1.0 / 2.4) * 1.055 - 0.055; + linear.rgb = mix(higher, lower, float3(cutoff)); + + return linear; +} +float unlinearize(float v) { + return v <= 0.0031308 ? v * 12.92 : pow(v, 1.0 / 2.4) * 1.055 - 0.055; +} + +// Compute the luminance of the provided color. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float luminance(float3 color) { + return dot(color, float3(0.2126f, 0.7152f, 0.0722f)); +} + +// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float contrast_ratio(float3 color1, float3 color2) { + float l1 = luminance(color1); + float l2 = luminance(color2); + return (max(l1, l2) + 0.05f) / (min(l1, l2) + 0.05f); +} + +// Return the fg if the contrast ratio is greater than min, otherwise +// return a color that satisfies the contrast ratio. Currently, the color +// is always white or black, whichever has the highest contrast ratio. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float4 contrasted_color(float min, float4 fg, float4 bg) { + float ratio = contrast_ratio(fg.rgb, bg.rgb); + if (ratio < min) { + float white_ratio = contrast_ratio(float3(1.0f), bg.rgb); + float black_ratio = contrast_ratio(float3(0.0f), bg.rgb); + if (white_ratio > black_ratio) { + return float4(1.0f); + } else { + return float4(0.0f, 0.0f, 0.0f, 1.0f); + } + } + + return fg; +} + +// Load a 4 byte RGBA non-premultiplied color and linearize +// and convert it as necessary depending on the provided info. +// +// Returns a color in the Display P3 color space. +// +// If `display_p3` is true, then the provided color is assumed to +// already be in the Display P3 color space, otherwise it's treated +// as an sRGB color and is appropriately converted to Display P3. +// +// `linear` controls whether the returned color is linear or gamma encoded. +float4 load_color( + uchar4 in_color, + bool display_p3, + bool linear +) { + // 0 .. 255 -> 0.0 .. 1.0 + float4 color = float4(in_color) / 255.0f; + + // If our color is already in Display P3 and + // we aren't doing linear blending, then we + // already have the correct color here and + // can premultiply and return it. + if (display_p3 && !linear) { + color.rgb *= color.a; + return color; + } + + // The color is in either the sRGB or Display P3 color space, + // so in either case, it's a color space which uses the sRGB + // transfer function, so we can use one function in order to + // linearize it in either case. + // + // Even if we aren't doing linear blending, the color + // needs to be in linear space to convert color spaces. + color = linearize(color); + + // If we're *NOT* using display P3 colors, then we're dealing + // with an sRGB color, in which case we need to convert it in + // to the Display P3 color space, since our output is always + // Display P3. + if (!display_p3) { + color.rgb = srgb_to_display_p3(color.rgb); + } + + // If we're not doing linear blending, then we need to + // unlinearize after doing the color space conversion. + if (!linear) { + color = unlinearize(color); + } + + // Premultiply our color by its alpha. + color.rgb *= color.a; + + return color; +} + +//------------------------------------------------------------------- +// Full Screen Vertex Shader +//------------------------------------------------------------------- +#pragma mark - Full Screen Vertex Shader + +struct FullScreenVertexOut { + float4 position [[position]]; +}; + +vertex FullScreenVertexOut full_screen_vertex( + uint vid [[vertex_id]] +) { + FullScreenVertexOut out; + + float4 position; + position.x = (vid == 2) ? 3.0 : -1.0; + position.y = (vid == 0) ? -3.0 : 1.0; + position.zw = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + out.position = position; + + return out; +} + +//------------------------------------------------------------------- +// Background Color Shader +//------------------------------------------------------------------- +#pragma mark - BG Color Shader + +fragment float4 bg_color_fragment( + FullScreenVertexOut in [[stage_in]], + constant Uniforms& uniforms [[buffer(1)]] +) { + return load_color( + uniforms.bg_color, + uniforms.use_display_p3, + uniforms.use_linear_blending + ); +} + +//------------------------------------------------------------------- +// Background Image Shader +//------------------------------------------------------------------- +#pragma mark - BG Image Shader + +struct BgImageVertexIn { + float opacity [[attribute(0)]]; + uint8_t info [[attribute(1)]]; +}; + +enum BgImagePosition : uint8_t { + // 4 bits of info. + BG_IMAGE_POSITION = 15u, + + BG_IMAGE_TL = 0u, + BG_IMAGE_TC = 1u, + BG_IMAGE_TR = 2u, + BG_IMAGE_ML = 3u, + BG_IMAGE_MC = 4u, + BG_IMAGE_MR = 5u, + BG_IMAGE_BL = 6u, + BG_IMAGE_BC = 7u, + BG_IMAGE_BR = 8u, +}; + +enum BgImageFit : uint8_t { + // 2 bits of info shifted 4. + BG_IMAGE_FIT = 3u << 4, + + BG_IMAGE_CONTAIN = 0u << 4, + BG_IMAGE_COVER = 1u << 4, + BG_IMAGE_STRETCH = 2u << 4, + BG_IMAGE_NO_FIT = 3u << 4, +}; + +enum BgImageRepeat : uint8_t { + // 1 bit of info shifted 6. + BG_IMAGE_REPEAT = 1u << 6, +}; + +struct BgImageVertexOut { + float4 position [[position]]; + float4 bg_color [[flat]]; + float2 offset [[flat]]; + float2 scale [[flat]]; + float opacity [[flat]]; + bool repeat [[flat]]; +}; + +vertex BgImageVertexOut bg_image_vertex( + uint vid [[vertex_id]], + BgImageVertexIn in [[stage_in]], + texture2d<float> image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + BgImageVertexOut out; + + float4 position; + position.x = (vid == 2) ? 3.0 : -1.0; + position.y = (vid == 0) ? -3.0 : 1.0; + position.zw = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + out.position = position; + + out.opacity = in.opacity; + + out.repeat = (in.info & BG_IMAGE_REPEAT) == BG_IMAGE_REPEAT; + + float2 screen_size = uniforms.screen_size; + float2 tex_size = float2(image.get_width(), image.get_height()); + + float2 dest_size = tex_size; + switch (in.info & BG_IMAGE_FIT) { + // For `contain` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is smaller. + case BG_IMAGE_CONTAIN: { + float scale = min(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `cover` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is larger. + case BG_IMAGE_COVER: { + float scale = max(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `stretch` we stretch the image to the size of + // the screen without worrying about aspect ratio. + case BG_IMAGE_STRETCH: { + dest_size = screen_size; + } break; + + // For `none` we just use the original texture size. + case BG_IMAGE_NO_FIT: { + dest_size = tex_size; + } break; + } + + float2 start = float2(0.0); + float2 mid = (screen_size - dest_size) / 2; + float2 end = screen_size - dest_size; + + float2 dest_offset = mid; + switch (in.info & BG_IMAGE_POSITION) { + case BG_IMAGE_TL: { + dest_offset = float2(start.x, start.y); + } break; + case BG_IMAGE_TC: { + dest_offset = float2(mid.x, start.y); + } break; + case BG_IMAGE_TR: { + dest_offset = float2(end.x, start.y); + } break; + case BG_IMAGE_ML: { + dest_offset = float2(start.x, mid.y); + } break; + case BG_IMAGE_MC: { + dest_offset = float2(mid.x, mid.y); + } break; + case BG_IMAGE_MR: { + dest_offset = float2(end.x, mid.y); + } break; + case BG_IMAGE_BL: { + dest_offset = float2(start.x, end.y); + } break; + case BG_IMAGE_BC: { + dest_offset = float2(mid.x, end.y); + } break; + case BG_IMAGE_BR: { + dest_offset = float2(end.x, end.y); + } break; + } + + out.offset = dest_offset; + out.scale = tex_size / dest_size; + + // We load a fully opaque version of the bg color and combine it with + // the alpha separately, because we need these as separate values in + // the framgment shader. + out.bg_color = float4(load_color( + uchar4(uniforms.bg_color.rgb, 255), + uniforms.use_display_p3, + uniforms.use_linear_blending + ).rgb, float(uniforms.bg_color.a) / 255.0); + + return out; +} + +fragment float4 bg_image_fragment( + BgImageVertexOut in [[stage_in]], + texture2d<float> image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + constexpr sampler textureSampler( + coord::pixel, + address::clamp_to_zero, + filter::linear + ); + + // Our texture coordinate is based on the screen position, offset by the + // dest rect origin, and scaled by the ratio between the dest rect size + // and the original texture size, which effectively scales the original + // size of the texture to the dest rect size. + float2 tex_coord = (in.position.xy - in.offset) * in.scale; + + // If we need to repeat the texture, wrap the coordinates. + if (in.repeat) { + float2 tex_size = float2(image.get_width(), image.get_height()); + + tex_coord = fmod(fmod(tex_coord, tex_size) + tex_size, tex_size); + } + + float4 rgba = image.sample(textureSampler, tex_coord); + + if (!uniforms.use_linear_blending) { + rgba = unlinearize(rgba); + } + + // Premultiply the bg image. + rgba.rgb *= rgba.a; + + // Multiply it by the configured opacity, but cap it at + // the value that will make it fully opaque relative to + // the background color alpha, so it isn't overexposed. + rgba *= min(in.opacity, 1.0 / in.bg_color.a); + + // Blend it on to a fully opaque version of the background color. + rgba += max(float4(0.0), float4(in.bg_color.rgb, 1.0) * (1.0 - rgba.a)); + + // Multiply everything by the background color alpha. + rgba *= in.bg_color.a; + + return rgba; +} + +//------------------------------------------------------------------- +// Cell Background Shader +//------------------------------------------------------------------- +#pragma mark - Cell BG Shader + +fragment float4 cell_bg_fragment( + FullScreenVertexOut in [[stage_in]], + constant Uniforms& uniforms [[buffer(1)]], + constant uchar4 *cells [[buffer(2)]] +) { + int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size)); + + float4 bg = float4(0.0); + + // Clamp x position, extends edge bg colors in to padding on sides. + if (grid_pos.x < 0) { + if (uniforms.padding_extend & EXTEND_LEFT) { + grid_pos.x = 0; + } else { + return bg; + } + } else if (grid_pos.x > uniforms.grid_size.x - 1) { + if (uniforms.padding_extend & EXTEND_RIGHT) { + grid_pos.x = uniforms.grid_size.x - 1; + } else { + return bg; + } + } + + // Clamp y position if we should extend, otherwise discard if out of bounds. + if (grid_pos.y < 0) { + if (uniforms.padding_extend & EXTEND_UP) { + grid_pos.y = 0; + } else { + return bg; + } + } else if (grid_pos.y > uniforms.grid_size.y - 1) { + if (uniforms.padding_extend & EXTEND_DOWN) { + grid_pos.y = uniforms.grid_size.y - 1; + } else { + return bg; + } + } + + // Load the color for the cell. + uchar4 cell_color = cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]; + + // Convert the color and return it. + // + // TODO: It might be a good idea to do a pass before this + // to convert all of the bg colors, so we don't waste + // a bunch of work converting the cell color in every + // fragment of each cell. It's not the most epxensive + // operation, but it is still wasted work. + return load_color( + cell_color, + uniforms.use_display_p3, + uniforms.use_linear_blending + ); +} + +//------------------------------------------------------------------- +// Cell Text Shader +//------------------------------------------------------------------- +#pragma mark - Cell Text Shader + +enum CellTextAtlas : uint8_t { + ATLAS_GRAYSCALE = 0u, + ATLAS_COLOR = 1u, +}; + +// We use a packed struct of bools for misc properties of the glyph. +enum CellTextBools : uint8_t { + // Don't apply min contrast to this glyph. + NO_MIN_CONTRAST = 1u, + // This is the cursor glyph. + IS_CURSOR_GLYPH = 2u, +}; + +struct CellTextVertexIn { + // The position of the glyph in the texture (x, y) + uint2 glyph_pos [[attribute(0)]]; + + // The size of the glyph in the texture (w, h) + uint2 glyph_size [[attribute(1)]]; + + // The left and top bearings for the glyph (x, y) + int2 bearings [[attribute(2)]]; + + // The grid coordinates (x, y) where x < columns and y < rows + ushort2 grid_pos [[attribute(3)]]; + + // The color of the rendered text glyph. + uchar4 color [[attribute(4)]]; + + // Which atlas to sample for our glyph. + uint8_t atlas [[attribute(5)]]; + + // Misc properties of the glyph. + uint8_t bools [[attribute(6)]]; +}; + +struct CellTextVertexOut { + float4 position [[position]]; + uint8_t atlas [[flat]]; + float4 color [[flat]]; + float4 bg_color [[flat]]; + float2 tex_coord; +}; + +vertex CellTextVertexOut cell_text_vertex( + uint vid [[vertex_id]], + CellTextVertexIn in [[stage_in]], + constant Uniforms& uniforms [[buffer(1)]], + constant uchar4 *bg_colors [[buffer(2)]] +) { + // Convert the grid x, y into world space x, y by accounting for cell size + float2 cell_pos = uniforms.cell_size * float2(in.grid_pos); + + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. + // + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) + float2 corner; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); + + CellTextVertexOut out; + out.atlas = in.atlas; + + // === Grid Cell === + // +X + // 0,0--...-> + // | + // . offset.x = bearings.x + // +Y. .|. + // . | | + // | cell_pos -> +-------+ _. + // v ._| |_. _|- offset.y = cell_size.y - bearings.y + // | | .###. | | + // | | #...# | | + // glyph_size.y -+ | ##### | | + // | | #.... | +- bearings.y + // |_| .#### | | + // | |_| + // +-------+ + // |_._| + // | + // glyph_size.x + // + // In order to get the top left of the glyph, we compute an offset based on + // the bearings. The Y bearing is the distance from the bottom of the cell + // to the top of the glyph, so we subtract it from the cell height to get + // the y offset. The X bearing is the distance from the left of the cell + // to the left of the glyph, so it works as the x offset directly. + + float2 size = float2(in.glyph_size); + float2 offset = float2(in.bearings); + + offset.y = uniforms.cell_size.y - offset.y; + + // Calculate the final position of the cell which uses our glyph size + // and glyph offset to create the correct bounding box for the glyph. + cell_pos = cell_pos + size * corner + offset; + out.position = + uniforms.projection_matrix * float4(cell_pos.x, cell_pos.y, 0.0f, 1.0f); + + // Calculate the texture coordinate in pixels. This is NOT normalized + // (between 0.0 and 1.0), and does not need to be, since the texture will + // be sampled with pixel coordinate mode. + out.tex_coord = float2(in.glyph_pos) + float2(in.glyph_size) * corner; + + // Get our color. We always fetch a linearized version to + // make it easier to handle minimum contrast calculations. + out.color = load_color( + in.color, + uniforms.use_display_p3, + true + ); + + // Get the BG color + out.bg_color = load_color( + bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x], + uniforms.use_display_p3, + true + ); + // Blend it with the global bg color + float4 global_bg = load_color( + uniforms.bg_color, + uniforms.use_display_p3, + true + ); + out.bg_color += global_bg * (1.0 - out.bg_color.a); + + // If we have a minimum contrast, we need to check if we need to + // change the color of the text to ensure it has enough contrast + // with the background. + if (uniforms.min_contrast > 1.0f && (in.bools & NO_MIN_CONTRAST) == 0) { + // Ensure our minimum contrast + out.color = contrasted_color(uniforms.min_contrast, out.color, out.bg_color); + } + + // Check if current position is under cursor (including wide cursor) + bool is_cursor_pos = ( + in.grid_pos.x == uniforms.cursor_pos.x || + uniforms.cursor_wide && + in.grid_pos.x == uniforms.cursor_pos.x + 1 + ) && in.grid_pos.y == uniforms.cursor_pos.y; + + // If this cell is the cursor cell, but we're not processing + // the cursor glyph itself, then we need to change the color. + if ((in.bools & IS_CURSOR_GLYPH) == 0 && is_cursor_pos) { + out.color = load_color( + uniforms.cursor_color, + uniforms.use_display_p3, + true + ); + } + + return out; +} + +fragment float4 cell_text_fragment( + CellTextVertexOut in [[stage_in]], + texture2d<float> textureGrayscale [[texture(0)]], + texture2d<float> textureColor [[texture(1)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + constexpr sampler textureSampler( + coord::pixel, + address::clamp_to_edge, + filter::nearest + ); + + switch (in.atlas) { + default: + case ATLAS_GRAYSCALE: { + // Our input color is always linear. + float4 color = in.color; + + // If we're not doing linear blending, then we need to + // re-apply the gamma encoding to our color manually. + // + // Since the alpha is premultiplied, we need to divide + // it out before unlinearizing and re-multiply it after. + if (!uniforms.use_linear_blending) { + color.rgb /= color.a; + color = unlinearize(color); + color.rgb *= color.a; + } + + // Fetch our alpha mask for this pixel. + float a = textureGrayscale.sample(textureSampler, in.tex_coord).r; + + // Linear blending weight correction corrects the alpha value to + // produce blending results which match gamma-incorrect blending. + if (uniforms.use_linear_correction) { + // Short explanation of how this works: + // + // We get the luminances of the foreground and background colors, + // and then unlinearize them and perform blending on them. This + // gives us our desired luminance, which we derive our new alpha + // value from by mapping the range [bg_l, fg_l] to [0, 1], since + // our final blend will be a linear interpolation from bg to fg. + // + // This yields virtually identical results for grayscale blending, + // and very similar but non-identical results for color blending. + float4 bg = in.bg_color; + float fg_l = luminance(color.rgb); + float bg_l = luminance(bg.rgb); + // To avoid numbers going haywire, we don't apply correction + // when the bg and fg luminances are within 0.001 of each other. + if (abs(fg_l - bg_l) > 0.001) { + float blend_l = linearize(unlinearize(fg_l) * a + unlinearize(bg_l) * (1.0 - a)); + a = clamp((blend_l - bg_l) / (fg_l - bg_l), 0.0, 1.0); + } + } + + // Multiply our whole color by the alpha mask. + // Since we use premultiplied alpha, this is + // the correct way to apply the mask. + color *= a; + + return color; + } + + case ATLAS_COLOR: { + // For now, we assume that color glyphs + // are already premultiplied linear colors. + float4 color = textureColor.sample(textureSampler, in.tex_coord); + + // If we're doing linear blending, we can return this right away. + if (uniforms.use_linear_blending) { + return color; + } + + // Otherwise we need to unlinearize the color. Since the alpha is + // premultiplied, we need to divide it out before unlinearizing. + color.rgb /= color.a; + color = unlinearize(color); + color.rgb *= color.a; + + return color; + } + } +} +//------------------------------------------------------------------- +// Image Shader +//------------------------------------------------------------------- +#pragma mark - Image Shader + +struct ImageVertexIn { + // The grid coordinates (x, y) where x < columns and y < rows where + // the image will be rendered. It will be rendered from the top left. + float2 grid_pos [[attribute(0)]]; + + // Offset in pixels from the top-left of the cell to make the top-left + // corner of the image. + float2 cell_offset [[attribute(1)]]; + + // The source rectangle of the texture to sample from. + float4 source_rect [[attribute(2)]]; + + // The final width/height of the image in pixels. + float2 dest_size [[attribute(3)]]; +}; + +struct ImageVertexOut { + float4 position [[position]]; + float2 tex_coord; +}; + +vertex ImageVertexOut image_vertex( + uint vid [[vertex_id]], + ImageVertexIn in [[stage_in]], + texture2d<uint> image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. + // + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) + float2 corner; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); + + // The texture coordinates start at our source x/y + // and add the width/height depending on the corner. + // + // We don't need to normalize because we use pixel addressing for our sampler. + float2 tex_coord = in.source_rect.xy; + tex_coord += in.source_rect.zw * corner; + + ImageVertexOut out; + + // The position of our image starts at the top-left of the grid cell and + // adds the source rect width/height components. + float2 image_pos = (uniforms.cell_size * in.grid_pos) + in.cell_offset; + image_pos += in.dest_size * corner; + + out.position = + uniforms.projection_matrix * float4(image_pos.x, image_pos.y, 0.0f, 1.0f); + out.tex_coord = tex_coord; + return out; +} + +fragment float4 image_fragment( + ImageVertexOut in [[stage_in]], + texture2d<float> image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + constexpr sampler textureSampler( + coord::pixel, + address::clamp_to_edge, + filter::linear + ); + + float4 rgba = image.sample(textureSampler, in.tex_coord); + + if (!uniforms.use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= rgba.a; + + return rgba; +} + diff --git a/vendor/libghostty-vt/src/renderer/shaders/shadertoy_prefix.glsl b/vendor/libghostty-vt/src/renderer/shaders/shadertoy_prefix.glsl new file mode 100644 index 00000000..4b6d091b --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/shadertoy_prefix.glsl @@ -0,0 +1,52 @@ +#version 430 core + +layout(binding = 1, std140) uniform Globals { + uniform vec3 iResolution; + uniform float iTime; + uniform float iTimeDelta; + uniform float iFrameRate; + uniform int iFrame; + uniform float iChannelTime[4]; + uniform vec3 iChannelResolution[4]; + uniform vec4 iMouse; + uniform vec4 iDate; + uniform float iSampleRate; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; + uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionForegroundColor; + uniform vec3 iSelectionBackgroundColor; +}; + +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + +layout(binding = 0) uniform sampler2D iChannel0; + +// These are unused currently by Ghostty: +// layout(binding = 1) uniform sampler2D iChannel1; +// layout(binding = 2) uniform sampler2D iChannel2; +// layout(binding = 3) uniform sampler2D iChannel3; + +layout(location = 0) in vec4 gl_FragCoord; +layout(location = 0) out vec4 _fragColor; + +#define texture2D texture + +void mainImage( out vec4 fragColor, in vec2 fragCoord ); +void main() { mainImage (_fragColor, gl_FragCoord.xy); } diff --git a/vendor/libghostty-vt/src/renderer/shaders/test_shadertoy_crt.glsl b/vendor/libghostty-vt/src/renderer/shaders/test_shadertoy_crt.glsl new file mode 100644 index 00000000..b8d6dbb4 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/test_shadertoy_crt.glsl @@ -0,0 +1,59 @@ +// This shader is NOT BUILD INTO Ghostty. This is only here for unit tests. + +// Loosely based on postprocessing shader by inigo quilez, License Creative +// Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License. + +vec2 curve(vec2 uv) +{ + uv = (uv - 0.5) * 2.0; + uv *= 1.1; + uv.x *= 1.0 + pow((abs(uv.y) / 5.0), 2.0); + uv.y *= 1.0 + pow((abs(uv.x) / 4.0), 2.0); + uv = (uv / 2.0) + 0.5; + uv = uv *0.92 + 0.04; + return uv; +} +void mainImage( out vec4 fragColor, in vec2 fragCoord ) +{ + vec2 q = fragCoord.xy / iResolution.xy; + vec2 uv = q; + uv = curve( uv ); + vec3 oricol = texture( iChannel0, vec2(q.x,q.y) ).xyz; + vec3 col; + float x = sin(0.3*iTime+uv.y*21.0)*sin(0.7*iTime+uv.y*29.0)*sin(0.3+0.33*iTime+uv.y*31.0)*0.0017; + + col.r = texture(iChannel0,vec2(x+uv.x+0.001,uv.y+0.001)).x+0.05; + col.g = texture(iChannel0,vec2(x+uv.x+0.000,uv.y-0.002)).y+0.05; + col.b = texture(iChannel0,vec2(x+uv.x-0.002,uv.y+0.000)).z+0.05; + col.r += 0.08*texture(iChannel0,0.75*vec2(x+0.025, -0.027)+vec2(uv.x+0.001,uv.y+0.001)).x; + col.g += 0.05*texture(iChannel0,0.75*vec2(x+-0.022, -0.02)+vec2(uv.x+0.000,uv.y-0.002)).y; + col.b += 0.08*texture(iChannel0,0.75*vec2(x+-0.02, -0.018)+vec2(uv.x-0.002,uv.y+0.000)).z; + + col = clamp(col*0.6+0.4*col*col*1.0,0.0,1.0); + + float vig = (0.0 + 1.0*16.0*uv.x*uv.y*(1.0-uv.x)*(1.0-uv.y)); + col *= vec3(pow(vig,0.3)); + + col *= vec3(0.95,1.05,0.95); + col *= 2.8; + + float scans = clamp( 0.35+0.35*sin(3.5*iTime+uv.y*iResolution.y*1.5), 0.0, 1.0); + + float s = pow(scans,1.7); + col = col*vec3( 0.4+0.7*s) ; + + col *= 1.0+0.01*sin(110.0*iTime); + if (uv.x < 0.0 || uv.x > 1.0) + col *= 0.0; + if (uv.y < 0.0 || uv.y > 1.0) + col *= 0.0; + + col*=1.0-0.65*vec3(clamp((mod(fragCoord.x, 2.0)-1.0)*2.0,0.0,1.0)); + + float comp = smoothstep( 0.1, 0.9, sin(iTime) ); + + // Remove the next line to stop cross-fade between original and postprocess +// col = mix( col, oricol, comp ); + + fragColor = vec4(col,1.0); +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/test_shadertoy_focus.glsl b/vendor/libghostty-vt/src/renderer/shaders/test_shadertoy_focus.glsl new file mode 100644 index 00000000..9fc2304d --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/test_shadertoy_focus.glsl @@ -0,0 +1,41 @@ +// Test shader for iTimeFocus and iFocus +// Shows border when focused, green fade that restarts on each focus gain +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + + // Sample the terminal content + vec4 terminal = texture2D(iChannel0, uv); + vec3 color = terminal.rgb; + + if (iFocus > 0) { + // FOCUSED: Add border and fading green overlay + + // Calculate time since focus was gained + float timeSinceFocus = iTime - iTimeFocus; + + // Green fade: starts at 1.0 (full green), fades to 0.0 over 3 seconds + float fadeOut = max(0.0, 1.0 - (timeSinceFocus / 3.0)); + + // Add green overlay that fades out + color = mix(color, vec3(0.0, 1.0, 0.0), fadeOut * 0.4); + + // Add border (5 pixels) + float borderSize = 5.0; + vec2 pixelCoord = fragCoord; + bool isBorder = pixelCoord.x < borderSize || + pixelCoord.x > iResolution.x - borderSize || + pixelCoord.y < borderSize || + pixelCoord.y > iResolution.y - borderSize; + + if (isBorder) { + // Bright cyan border that pulses subtly + float pulse = sin(timeSinceFocus * 2.0) * 0.1 + 0.9; + color = vec3(0.0, 1.0, 1.0) * pulse; + } + } else { + // UNFOCUSED: Solid red overlay (no border) + color = mix(color, vec3(1.0, 0.0, 0.0), 0.3); + } + + fragColor = vec4(color, 1.0); +} diff --git a/vendor/libghostty-vt/src/renderer/shaders/test_shadertoy_invalid.glsl b/vendor/libghostty-vt/src/renderer/shaders/test_shadertoy_invalid.glsl new file mode 100644 index 00000000..1cff1c7c --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shaders/test_shadertoy_invalid.glsl @@ -0,0 +1,12 @@ +vec2 curve(vec2 uv) +{ + uv = (uv - 0.5) * 2.0; + uv *= 1.1; + uv.x *= 1.0 + pow((abs(uv.y) / 5.0), 2.0); + uv.y *= 1.0 + pow((abs(uv.x) / 4.0), 2.0); + uv = (uv / 2.0) + 0.5; + uv = uv *0.92 + 0.04; + return uv; +} + +// Missing mainImage! diff --git a/vendor/libghostty-vt/src/renderer/shadertoy.zig b/vendor/libghostty-vt/src/renderer/shadertoy.zig new file mode 100644 index 00000000..556c2829 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/shadertoy.zig @@ -0,0 +1,427 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const glslang = @import("glslang"); +const spvcross = @import("spirv_cross"); +const configpkg = @import("../config.zig"); + +const log = std.log.scoped(.shadertoy); + +/// The uniform struct used for shadertoy shaders. +pub const Uniforms = extern struct { + resolution: [3]f32 align(16), + time: f32 align(4), + time_delta: f32 align(4), + frame_rate: f32 align(4), + frame: i32 align(4), + channel_time: [4][4]f32 align(16), + channel_resolution: [4][4]f32 align(16), + mouse: [4]f32 align(16), + date: [4]f32 align(16), + sample_rate: f32 align(4), + current_cursor: [4]f32 align(16), + previous_cursor: [4]f32 align(16), + current_cursor_color: [4]f32 align(16), + previous_cursor_color: [4]f32 align(16), + current_cursor_style: i32 align(4), + previous_cursor_style: i32 align(4), + cursor_visible: i32 align(4), + cursor_change_time: f32 align(4), + time_focus: f32 align(4), + focus: i32 align(4), + palette: [256][4]f32 align(16), + background_color: [4]f32 align(16), + foreground_color: [4]f32 align(16), + cursor_color: [4]f32 align(16), + cursor_text: [4]f32 align(16), + selection_background_color: [4]f32 align(16), + selection_foreground_color: [4]f32 align(16), +}; + +/// The target to load shaders for. +pub const Target = enum { glsl, msl }; + +/// Load a set of shaders from files and convert them to the target +/// format. The shader order is preserved. +pub fn loadFromFiles( + alloc_gpa: Allocator, + paths: configpkg.RepeatablePath, + target: Target, +) ![]const [:0]const u8 { + var list: std.ArrayList([:0]const u8) = .empty; + defer list.deinit(alloc_gpa); + errdefer for (list.items) |shader| alloc_gpa.free(shader); + + for (paths.value.items) |item| { + const path, const optional = switch (item) { + .optional => |path| .{ path, true }, + .required => |path| .{ path, false }, + }; + + const shader = loadFromFile(alloc_gpa, path, target) catch |err| { + if (err == error.FileNotFound and optional) { + continue; + } + + return err; + }; + log.info("loaded custom shader path={s}", .{path}); + try list.append(alloc_gpa, shader); + } + + return try list.toOwnedSlice(alloc_gpa); +} + +/// Load a single shader from a file and convert it to the target language +/// ready to be used with renderers. +pub fn loadFromFile( + alloc_gpa: Allocator, + path: []const u8, + target: Target, +) ![:0]const u8 { + var arena = ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + // Read it all into memory -- we don't expect shaders to be large. + const src = src: { + // Load the shader file + const cwd = std.fs.cwd(); + const file = try cwd.openFile(path, .{}); + defer file.close(); + + break :src try file.readToEndAlloc( + alloc, + 4 * 1024 * 1024, // 4MB + ); + }; + + // Convert to full GLSL + const glsl: [:0]const u8 = glsl: { + var stream: std.Io.Writer.Allocating = .init(alloc); + try glslFromShader(&stream.writer, src); + try stream.writer.writeByte(0); + break :glsl stream.written()[0 .. stream.written().len - 1 :0]; + }; + + // Convert to SPIR-V + const spirv: []const u8 = spirv: { + var stream: std.Io.Writer.Allocating = .init(alloc); + var errlog: SpirvLog = .{ .alloc = alloc }; + defer errlog.deinit(); + spirvFromGlsl(&stream.writer, &errlog, glsl) catch |err| { + if (errlog.info.len > 0 or errlog.debug.len > 0) { + log.warn("spirv error path={s} info={s} debug={s}", .{ + path, + errlog.info, + errlog.debug, + }); + } + + return err; + }; + + // SpirV pointer must be aligned to 4 bytes since we expect + // a slice of words. + var list: std.ArrayListAligned(u8, .of(u32)) = .empty; + try list.appendSlice(alloc, stream.written()); + break :spirv list.items; + }; + + // Convert to MSL + return switch (target) { + // Important: using the alloc_gpa here on purpose because this + // is the final result that will be returned to the caller. + .glsl => try glslFromSpv(alloc_gpa, spirv), + .msl => try mslFromSpv(alloc_gpa, spirv), + }; +} + +/// Convert a ShaderToy shader into valid GLSL. +/// +/// ShaderToy shaders aren't full shaders, they're just implementing a +/// mainImage function and don't define any of the uniforms. This function +/// will convert the ShaderToy shader into a valid GLSL shader that can be +/// compiled and linked. +pub fn glslFromShader(writer: *std.Io.Writer, src: []const u8) !void { + const prefix = @embedFile("shaders/shadertoy_prefix.glsl"); + try writer.writeAll(prefix); + try writer.writeAll("\n\n"); + try writer.writeAll(src); +} + +/// Convert a GLSL shader into SPIR-V assembly. +pub fn spirvFromGlsl( + writer: *std.Io.Writer, + errlog: ?*SpirvLog, + src: [:0]const u8, +) !void { + // So we can run unit tests without fear. + if (builtin.is_test) try glslang.testing.ensureInit(); + + const c = glslang.c; + const input: c.glslang_input_t = .{ + .language = c.GLSLANG_SOURCE_GLSL, + .stage = c.GLSLANG_STAGE_FRAGMENT, + .client = c.GLSLANG_CLIENT_VULKAN, + .client_version = c.GLSLANG_TARGET_VULKAN_1_2, + .target_language = c.GLSLANG_TARGET_SPV, + .target_language_version = c.GLSLANG_TARGET_SPV_1_5, + .code = src.ptr, + .default_version = 100, + .default_profile = c.GLSLANG_NO_PROFILE, + .force_default_version_and_profile = 0, + .forward_compatible = 0, + .messages = c.GLSLANG_MSG_DEFAULT_BIT, + .resource = c.glslang_default_resource(), + }; + + const shader = try glslang.Shader.create(&input); + defer shader.delete(); + + shader.preprocess(&input) catch |err| { + if (errlog) |ptr| ptr.fromShader(shader) catch {}; + return err; + }; + shader.parse(&input) catch |err| { + if (errlog) |ptr| ptr.fromShader(shader) catch {}; + return err; + }; + + const program = try glslang.Program.create(); + defer program.delete(); + program.addShader(shader); + program.link( + c.GLSLANG_MSG_SPV_RULES_BIT | + c.GLSLANG_MSG_VULKAN_RULES_BIT, + ) catch |err| { + if (errlog) |ptr| ptr.fromProgram(program) catch {}; + return err; + }; + program.spirvGenerate(c.GLSLANG_STAGE_FRAGMENT); + const size = program.spirvGetSize(); + const ptr = try program.spirvGetPtr(); + const ptr_u8: [*]u8 = @ptrCast(ptr); + const slice_u8: []u8 = ptr_u8[0 .. size * 4]; + try writer.writeAll(slice_u8); +} + +/// Retrieve errors from spirv compilation. +pub const SpirvLog = struct { + alloc: Allocator, + info: [:0]const u8 = "", + debug: [:0]const u8 = "", + + pub fn deinit(self: *const SpirvLog) void { + if (self.info.len > 0) self.alloc.free(self.info); + if (self.debug.len > 0) self.alloc.free(self.debug); + } + + fn fromShader(self: *SpirvLog, shader: *glslang.Shader) !void { + const info = try shader.getInfoLog(); + const debug = try shader.getDebugInfoLog(); + self.info = ""; + self.debug = ""; + if (info.len > 0) self.info = try self.alloc.dupeZ(u8, info); + if (debug.len > 0) self.debug = try self.alloc.dupeZ(u8, debug); + } + + fn fromProgram(self: *SpirvLog, program: *glslang.Program) !void { + const info = try program.getInfoLog(); + const debug = try program.getDebugInfoLog(); + self.info = ""; + self.debug = ""; + if (info.len > 0) self.info = try self.alloc.dupeZ(u8, info); + if (debug.len > 0) self.debug = try self.alloc.dupeZ(u8, debug); + } +}; + +/// Convert SPIR-V binary to MSL. +pub fn mslFromSpv(alloc: Allocator, spv: []const u8) ![:0]const u8 { + const c = spvcross.c; + return try spvCross(alloc, spvcross.c.SPVC_BACKEND_MSL, spv, (struct { + fn setOptions(options: c.spvc_compiler_options) error{SpvcFailed}!void { + // We enable decoration binding, because we need this + // to properly locate the uniform block to index 1. + if (c.spvc_compiler_options_set_bool( + options, + c.SPVC_COMPILER_OPTION_MSL_ENABLE_DECORATION_BINDING, + c.SPVC_TRUE, + ) != c.SPVC_SUCCESS) { + return error.SpvcFailed; + } + } + }).setOptions); +} + +/// Convert SPIR-V binary to GLSL. +pub fn glslFromSpv(alloc: Allocator, spv: []const u8) ![:0]const u8 { + const GLSL_VERSION = 430; + + const c = spvcross.c; + return try spvCross(alloc, c.SPVC_BACKEND_GLSL, spv, (struct { + fn setOptions(options: c.spvc_compiler_options) error{SpvcFailed}!void { + if (c.spvc_compiler_options_set_uint( + options, + c.SPVC_COMPILER_OPTION_GLSL_VERSION, + GLSL_VERSION, + ) != c.SPVC_SUCCESS) { + return error.SpvcFailed; + } + } + }).setOptions); +} + +fn spvCross( + alloc: Allocator, + backend: spvcross.c.spvc_backend, + spv: []const u8, + comptime optionsFn_: ?*const fn (c: spvcross.c.spvc_compiler_options) error{SpvcFailed}!void, +) ![:0]const u8 { + // Spir-V is always a multiple of 4 because it is written as a series of words + if (@mod(spv.len, 4) != 0) return error.SpirvInvalid; + + // Compiler context + const c = spvcross.c; + var ctx: c.spvc_context = undefined; + if (c.spvc_context_create(&ctx) != c.SPVC_SUCCESS) return error.SpvcFailed; + defer c.spvc_context_destroy(ctx); + + // It would be better to get this out into an output parameter to + // show users but for now we can just log it. + c.spvc_context_set_error_callback(ctx, @ptrCast(&(struct { + fn callback(_: ?*anyopaque, msg_ptr: [*c]const u8) callconv(.c) void { + const msg = std.mem.sliceTo(msg_ptr, 0); + std.log.warn("spirv-cross error message={s}", .{msg}); + } + }).callback), null); + + // Parse the Spir-V binary to an IR + var ir: c.spvc_parsed_ir = undefined; + if (c.spvc_context_parse_spirv( + ctx, + @ptrCast(@alignCast(spv.ptr)), + spv.len / 4, + &ir, + ) != c.SPVC_SUCCESS) { + return error.SpvcFailed; + } + + // Build our compiler to GLSL + var compiler: c.spvc_compiler = undefined; + if (c.spvc_context_create_compiler( + ctx, + backend, + ir, + c.SPVC_CAPTURE_MODE_TAKE_OWNERSHIP, + &compiler, + ) != c.SPVC_SUCCESS) { + return error.SpvcFailed; + } + + // Setup our options if we have any + if (optionsFn_) |optionsFn| { + var options: c.spvc_compiler_options = undefined; + if (c.spvc_compiler_create_compiler_options(compiler, &options) != c.SPVC_SUCCESS) { + return error.SpvcFailed; + } + + try optionsFn(options); + + if (c.spvc_compiler_install_compiler_options(compiler, options) != c.SPVC_SUCCESS) { + return error.SpvcFailed; + } + } + + // Compile the resulting string. This string pointer is owned by the + // context so we don't need to free it. + var result: [*:0]const u8 = undefined; + if (c.spvc_compiler_compile(compiler, @ptrCast(&result)) != c.SPVC_SUCCESS) { + return error.SpvcFailed; + } + + return try alloc.dupeZ(u8, std.mem.sliceTo(result, 0)); +} + +/// Convert ShaderToy shader to null-terminated glsl for testing. +fn testGlslZ(alloc: Allocator, src: []const u8) ![:0]const u8 { + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try glslFromShader(&buf.writer, src); + return try buf.toOwnedSliceSentinel(0); +} + +test "spirv" { + const testing = std.testing; + const alloc = testing.allocator; + + const src = try testGlslZ(alloc, test_crt); + defer alloc.free(src); + + var buf: [4096 * 4]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try spirvFromGlsl(&writer, null, src); +} + +test "spirv invalid" { + const testing = std.testing; + const alloc = testing.allocator; + + const src = try testGlslZ(alloc, test_invalid); + defer alloc.free(src); + + var buf: [4096 * 4]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + var errlog: SpirvLog = .{ .alloc = alloc }; + defer errlog.deinit(); + try testing.expectError(error.GlslangFailed, spirvFromGlsl(&writer, &errlog, src)); + try testing.expect(errlog.info.len > 0); +} + +test "shadertoy to msl" { + const testing = std.testing; + const alloc = testing.allocator; + + const src = try testGlslZ(alloc, test_crt); + defer alloc.free(src); + + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try spirvFromGlsl(&buf.writer, null, src); + + // TODO: Replace this with an aligned version of Writer.Allocating + var spvlist: std.ArrayListAligned(u8, .of(u32)) = .empty; + defer spvlist.deinit(alloc); + try spvlist.appendSlice(alloc, buf.written()); + + const msl = try mslFromSpv(alloc, spvlist.items); + defer alloc.free(msl); +} + +test "shadertoy to glsl" { + const testing = std.testing; + const alloc = testing.allocator; + + const src = try testGlslZ(alloc, test_crt); + defer alloc.free(src); + + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try spirvFromGlsl(&buf.writer, null, src); + + // TODO: Replace this with an aligned version of Writer.Allocating + var spvlist: std.ArrayListAligned(u8, .of(u32)) = .empty; + defer spvlist.deinit(alloc); + try spvlist.appendSlice(alloc, buf.written()); + + const glsl = try glslFromSpv(alloc, spvlist.items); + defer alloc.free(glsl); + + // log.warn("glsl={s}", .{glsl}); +} + +const test_crt = @embedFile("shaders/test_shadertoy_crt.glsl"); +const test_invalid = @embedFile("shaders/test_shadertoy_invalid.glsl"); +const test_focus = @embedFile("shaders/test_shadertoy_focus.glsl"); diff --git a/vendor/libghostty-vt/src/renderer/size.zig b/vendor/libghostty-vt/src/renderer/size.zig new file mode 100644 index 00000000..6cf05f58 --- /dev/null +++ b/vendor/libghostty-vt/src/renderer/size.zig @@ -0,0 +1,458 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminal_size = @import("../terminal/size.zig"); + +const log = std.log.scoped(.renderer_size); + +/// Controls how extra whitespace around the terminal grid is distributed. +pub const PaddingBalance = enum { + /// No balancing; padding is applied as specified explicitly. + false, + /// Balances padding but caps the top padding so the first row doesn't + /// drift too far from the top of the window. Excess vertical space is + /// shifted to the bottom. + true, + /// Distributes leftover space equally on all sides so the grid is + /// centered within the screen. + equal, +}; + +/// All relevant sizes for a rendered terminal. These are all the sizes that +/// any functionality should need to know about the terminal in order to +/// convert between any coordinate systems. +/// +/// See the individual field type documentation for more information on each +/// field. One important note is that any pixel values should already be scaled +/// to the current DPI of the screen. If the DPI changes, the sizes should be +/// recalculated and we expect this to be done by the caller. +pub const Size = struct { + screen: ScreenSize, + cell: CellSize, + padding: Padding, + + /// Return the grid size for this size. The grid size is calculated by + /// taking the screen size, removing padding, and dividing by the cell + /// dimensions. + pub fn grid(self: Size) GridSize { + return .init(self.screen.subPadding(self.padding), self.cell); + } + + /// The size of the terminal. This is the same as the screen without + /// padding. + pub fn terminal(self: Size) ScreenSize { + return self.screen.subPadding(self.padding); + } + + /// Set the padding to be balanced around the grid. The balanced + /// padding is calculated AFTER the explicit padding is taken + /// into account. + pub fn balancePadding( + self: *Size, + explicit: Padding, + mode: PaddingBalance, + ) void { + // This ensure grid() does the right thing + self.padding = explicit; + + // Now we can calculate the balanced padding + self.padding = .balanced( + self.screen, + self.grid(), + self.cell, + ); + + switch (mode) { + .false => unreachable, + .equal => {}, + .true => { + // Cap the top padding to avoid excessive space above the + // first row. The maximum is the balanced explicit horizontal + // padding plus half a cell width. Any excess is shifted to + // the bottom. + const max_top = (explicit.left + explicit.right + self.cell.width) / 2; + const vshift = self.padding.top -| max_top; + self.padding.top -= vshift; + self.padding.bottom += vshift; + }, + } + } +}; + +/// A coordinate. This is defined as a tagged union to allow for different +/// coordinate systems to be represented. +/// +/// A coordinate is only valid within the context of a stable Size value. +/// If any of the sizes in the Size struct change, the coordinate is no +/// longer valid and must be recalculated. A conversion function is provided +/// to migrate to a new Size (which may result in failure). +/// +/// The coordinate systems are: +/// +/// * surface: (0, 0) is the top-left of the surface (with padding). Negative +/// values are allowed and are off the surface. Likewise, values greater +/// than the surface size are off the surface. Units are pixels. +/// +/// * terminal: (0, 0) is the top-left of the terminal grid. This is the +/// same as the surface but with the padding removed. Negative values and +/// values greater than the grid size are allowed and are off the terminal. +/// Units are pixels. +/// +/// * grid: (0, 0) is the top-left of the grid. Units are in cells. Negative +/// values are not allowed but values greater than the grid size are +/// possible and are off the grid. +/// +pub const Coordinate = union(enum) { + surface: Surface, + terminal: Terminal, + grid: Grid, + + pub const Tag = @typeInfo(Coordinate).@"union".tag_type.?; + pub const Surface = struct { x: f64, y: f64 }; + pub const Terminal = struct { x: f64, y: f64 }; + pub const Grid = struct { x: GridSize.Unit, y: GridSize.Unit }; + + /// Convert a coordinate to a different space within the same Size. + pub fn convert(self: Coordinate, to: Tag, size: Size) Coordinate { + // Unlikely fast-path but avoid work. + if (@as(Tag, self) == to) return self; + + // To avoid the combinatorial explosion of conversion functions, we + // convert to the surface system first and then reconvert from there. + const surface = self.convertToSurface(size); + + return switch (to) { + .surface => .{ .surface = surface }, + .terminal => .{ .terminal = .{ + .x = surface.x - @as(f64, @floatFromInt(size.padding.left)), + .y = surface.y - @as(f64, @floatFromInt(size.padding.top)), + } }, + .grid => grid: { + // Get rid of the padding. + const term = (Coordinate{ .surface = surface }).convert( + .terminal, + size, + ).terminal; + + // We need our grid to clamp + const grid = size.grid(); + + // Calculate the grid position. + const cell_width: f64 = @as(f64, @floatFromInt(size.cell.width)); + const cell_height: f64 = @as(f64, @floatFromInt(size.cell.height)); + const clamped_x: f64 = @max(0, term.x); + const clamped_y: f64 = @max(0, term.y); + const col: GridSize.Unit = @intFromFloat(clamped_x / cell_width); + const row: GridSize.Unit = @intFromFloat(clamped_y / cell_height); + const clamped_col: GridSize.Unit = @min(col, grid.columns - 1); + const clamped_row: GridSize.Unit = @min(row, grid.rows - 1); + break :grid .{ .grid = .{ .x = clamped_col, .y = clamped_row } }; + }, + }; + } + + /// Convert a coordinate to the surface coordinate system. + fn convertToSurface(self: Coordinate, size: Size) Surface { + return switch (self) { + .surface => |v| v, + .terminal => |v| .{ + .x = v.x + @as(f64, @floatFromInt(size.padding.left)), + .y = v.y + @as(f64, @floatFromInt(size.padding.top)), + }, + .grid => |v| grid: { + const col: f64 = @floatFromInt(v.x); + const row: f64 = @floatFromInt(v.y); + const cell_width: f64 = @floatFromInt(size.cell.width); + const cell_height: f64 = @floatFromInt(size.cell.height); + const padding_left: f64 = @floatFromInt(size.padding.left); + const padding_top: f64 = @floatFromInt(size.padding.top); + break :grid .{ + .x = col * cell_width + padding_left, + .y = row * cell_height + padding_top, + }; + }, + }; + } +}; + +/// The dimensions of a single "cell" in the terminal grid. +/// +/// The dimensions are dependent on the current loaded set of font glyphs. +/// We calculate the width based on the widest character and the height based +/// on the height requirement for an underscore (the "lowest" -- visually -- +/// character). +/// +/// The units for the width and height are in world space. They have to +/// be normalized for any renderer implementation. +pub const CellSize = extern struct { + width: u32, + height: u32, +}; + +/// The dimensions of the screen that the grid is rendered to. This is the +/// terminal screen, so it is likely a subset of the window size. The dimensions +/// should be in pixels. +pub const ScreenSize = extern struct { + width: u32, + height: u32, + + /// Subtract padding from the screen size. + pub fn subPadding(self: ScreenSize, padding: Padding) ScreenSize { + return .{ + .width = self.width -| (padding.left + padding.right), + .height = self.height -| (padding.top + padding.bottom), + }; + } + + /// Calculates the amount of blank space around the grid. This is possible + /// when padding isn't balanced. + /// + /// The "self" screen size here should be the unpadded screen. + pub fn blankPadding(self: ScreenSize, padding: Padding, grid: GridSize, cell: CellSize) Padding { + const grid_width = grid.columns * cell.width; + const grid_height = grid.rows * cell.height; + const padded_width = grid_width + (padding.left + padding.right); + const padded_height = grid_height + (padding.top + padding.bottom); + + // Note these have to use a saturating subtraction to avoid underflow + // because our padding can cause the padded sizes to be larger than + // our real screen if the screen is shrunk to a minimal size such + // as 1x1. + const leftover_width = self.width -| padded_width; + const leftover_height = self.height -| padded_height; + + return .{ + .top = 0, + .bottom = leftover_height, + .right = leftover_width, + .left = 0, + }; + } + + /// Returns true if two sizes are equal. + pub fn equals(self: ScreenSize, other: ScreenSize) bool { + return self.width == other.width and self.height == other.height; + } +}; + +/// The dimensions of the grid itself, in rows/columns units. +pub const GridSize = extern struct { + pub const Unit = terminal_size.CellCountInt; + + columns: Unit = 0, + rows: Unit = 0, + + /// Initialize a grid size based on a screen and cell size. + pub fn init(screen: ScreenSize, cell: CellSize) GridSize { + var result: GridSize = undefined; + result.update(screen, cell); + return result; + } + + /// Update the columns/rows for the grid based on the given screen and + /// cell size. + pub fn update(self: *GridSize, screen: ScreenSize, cell: CellSize) void { + const cell_width: f32 = @floatFromInt(cell.width); + const cell_height: f32 = @floatFromInt(cell.height); + const screen_width: f32 = @floatFromInt(screen.width); + const screen_height: f32 = @floatFromInt(screen.height); + const calc_cols: Unit = @intFromFloat(screen_width / cell_width); + const calc_rows: Unit = @intFromFloat(screen_height / cell_height); + self.columns = @max(1, calc_cols); + self.rows = @max(1, calc_rows); + } + + /// Returns true if two sizes are equal. + pub fn equals(self: GridSize, other: GridSize) bool { + return self.columns == other.columns and self.rows == other.rows; + } +}; + +/// The padding to add to a screen. +pub const Padding = extern struct { + top: u32 = 0, + bottom: u32 = 0, + right: u32 = 0, + left: u32 = 0, + + /// Returns padding that balances the whitespace around the screen + /// for the given grid and cell sizes. + pub fn balanced(screen: ScreenSize, grid: GridSize, cell: CellSize) Padding { + // Turn our cell sizes into floats for the math + const cell_width: f32 = @floatFromInt(cell.width); + const cell_height: f32 = @floatFromInt(cell.height); + + // The size of our full grid + const grid_width = @as(f32, @floatFromInt(grid.columns)) * cell_width; + const grid_height = @as(f32, @floatFromInt(grid.rows)) * cell_height; + + // The empty space to the right of a line and bottom of the last row + const space_right = @as(f32, @floatFromInt(screen.width)) - grid_width; + const space_bot = @as(f32, @floatFromInt(screen.height)) - grid_height; + + // The padding is split equally along both axes. + const padding_right = @floor(space_right / 2); + const padding_left = padding_right; + + const padding_bot = @floor(space_bot / 2); + const padding_top = padding_bot; + + const zero = @as(f32, 0); + return .{ + .top = @intFromFloat(@max(zero, padding_top)), + .bottom = @intFromFloat(@max(zero, padding_bot)), + .right = @intFromFloat(@max(zero, padding_right)), + .left = @intFromFloat(@max(zero, padding_left)), + }; + } + + /// Add another padding to this one + pub fn add(self: Padding, other: Padding) Padding { + return .{ + .top = self.top + other.top, + .bottom = self.bottom + other.bottom, + .right = self.right + other.right, + .left = self.left + other.left, + }; + } + + /// Equality test between two paddings. + pub fn eql(self: Padding, other: Padding) bool { + return self.top == other.top and + self.bottom == other.bottom and + self.right == other.right and + self.left == other.left; + } +}; + +test "Size.balancePadding equal distributes whitespace equally" { + const testing = std.testing; + + // screen=1050x850, cell=10x20, explicit=4 each side + // grid: (1050-8)/10=104 cols, (850-8)/20=42 rows + // leftover: 1050-1040=10 horizontal, 850-840=10 vertical + // balanced: left=right=5, top=bottom=5 + var size: Size = .{ + .screen = .{ .width = 1050, .height = 850 }, + .cell = .{ .width = 10, .height = 20 }, + .padding = .{}, + }; + size.balancePadding(.{ .top = 4, .bottom = 4, .left = 4, .right = 4 }, .equal); + try testing.expectEqual(size.padding.left, size.padding.right); + try testing.expectEqual(size.padding.top, size.padding.bottom); + try testing.expect(size.padding.top > 0); +} + +test "Size.balancePadding true shifts excess top to bottom" { + const testing = std.testing; + + // screen=1090x1070, cell=20x40, explicit=0 + // grid: 1090/20=54 cols, 1070/40=26 rows + // leftover: 1090-1080=10, 1070-1040=30 + // balanced: left=right=5, top=bottom=15 + // vshift cap: (0+0+20)/2=10, vshift=15-10=5 + // result: top=10, bottom=20 + var size: Size = .{ + .screen = .{ .width = 1090, .height = 1070 }, + .cell = .{ .width = 20, .height = 40 }, + .padding = .{}, + }; + size.balancePadding(.{}, .true); + try testing.expectEqual(size.padding.left, size.padding.right); + try testing.expect(size.padding.top < size.padding.bottom); + try testing.expectEqual(@as(u32, 10), size.padding.top); + try testing.expectEqual(@as(u32, 20), size.padding.bottom); +} + +test "Padding balanced on zero" { + // On some systems, our screen can be zero-sized for a bit, and we + // don't want to end up with negative padding. + const testing = std.testing; + const grid: GridSize = .{ .columns = 100, .rows = 37 }; + const cell: CellSize = .{ .width = 10, .height = 20 }; + const screen: ScreenSize = .{ .width = 0, .height = 0 }; + const padding = Padding.balanced(screen, grid, cell); + try testing.expectEqual(Padding{}, padding); +} + +test "GridSize update exact" { + const testing = std.testing; + + var grid: GridSize = .{}; + grid.update(.{ + .width = 100, + .height = 40, + }, .{ + .width = 5, + .height = 10, + }); + + try testing.expectEqual(@as(GridSize.Unit, 20), grid.columns); + try testing.expectEqual(@as(GridSize.Unit, 4), grid.rows); +} + +test "GridSize update rounding" { + const testing = std.testing; + + var grid: GridSize = .{}; + grid.update(.{ + .width = 20, + .height = 40, + }, .{ + .width = 6, + .height = 15, + }); + + try testing.expectEqual(@as(GridSize.Unit, 3), grid.columns); + try testing.expectEqual(@as(GridSize.Unit, 2), grid.rows); +} + +test "coordinate conversion" { + const testing = std.testing; + + // A size for testing purposes. Purposely easy to calculate numbers. + const test_size: Size = .{ + .screen = .{ + .width = 100, + .height = 100, + }, + + .cell = .{ + .width = 5, + .height = 10, + }, + + .padding = .{}, + }; + + // Each pair is a test case of [expected, actual]. We only test + // one-way conversion because conversion can be lossy due to clamping + // and so on. + const table: []const [2]Coordinate = &.{ + .{ + .{ .grid = .{ .x = 0, .y = 0 } }, + .{ .surface = .{ .x = 0, .y = 0 } }, + }, + .{ + .{ .grid = .{ .x = 1, .y = 0 } }, + .{ .surface = .{ .x = 6, .y = 0 } }, + }, + .{ + .{ .grid = .{ .x = 1, .y = 1 } }, + .{ .surface = .{ .x = 6, .y = 10 } }, + }, + .{ + .{ .grid = .{ .x = 0, .y = 0 } }, + .{ .surface = .{ .x = -10, .y = -10 } }, + }, + .{ + .{ .grid = .{ .x = test_size.grid().columns - 1, .y = test_size.grid().rows - 1 } }, + .{ .surface = .{ .x = 100_000, .y = 100_000 } }, + }, + }; + + for (table) |pair| { + const expected = pair[0]; + const actual = pair[1].convert(@as(Coordinate.Tag, expected), test_size); + try testing.expectEqual(expected, actual); + } +} |
