diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 99dbc838e..1baa93c80 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -7,6 +7,7 @@ pub const Metal = @This(); const std = @import("std"); const builtin = @import("builtin"); const glfw = @import("glfw"); +const wuffs = @import("wuffs"); const objc = @import("objc"); const macos = @import("macos"); const imgui = @import("imgui"); @@ -59,6 +60,9 @@ const glfwNative = glfw.Native(.{ const log = std.log.scoped(.metal); +/// The maximum size of a background image. +const max_image_size = 400 * 1024 * 1024; // 400MB + /// Allocator that can be used alloc: std.mem.Allocator, @@ -130,6 +134,16 @@ font_grid: *font.SharedGrid, font_shaper: font.Shaper, font_shaper_cache: font.ShaperCache, +/// The background image(s) to draw. Currently, we always draw the last image. +background_image: configpkg.SinglePath, + +/// The background image mode to use. +background_image_mode: configpkg.BackgroundImageMode, + +/// The current background image to draw. If it is null, then we will not +/// draw any background image. +current_background_image: ?Image = null, + /// The images that we may render. images: ImageMap = .{}, image_placements: ImagePlacementList = .{}, @@ -429,6 +443,9 @@ pub const DerivedConfig = struct { cursor_text: ?terminal.color.RGB, background: terminal.color.RGB, background_opacity: f64, + background_image: configpkg.SinglePath, + background_image_opacity: f32, + background_image_mode: configpkg.BackgroundImageMode, foreground: terminal.color.RGB, selection_background: ?terminal.color.RGB, selection_foreground: ?terminal.color.RGB, @@ -453,6 +470,9 @@ pub const DerivedConfig = struct { // Copy our shaders const custom_shaders = try config.@"custom-shader".clone(alloc); + // Copy our background image + const background_image = try config.@"background-image".clone(alloc); + // Copy our font features const font_features = try config.@"font-feature".clone(alloc); @@ -493,6 +513,11 @@ pub const DerivedConfig = struct { .background = config.background.toTerminalRGB(), .foreground = config.foreground.toTerminalRGB(), + + .background_image = background_image, + .background_image_opacity = config.@"background-image-opacity", + .background_image_mode = config.@"background-image-mode", + .invert_selection_fg_bg = config.@"selection-invert-fg-bg", .bold_is_bright = config.@"bold-is-bright", .min_contrast = @floatCast(config.@"minimum-contrast"), @@ -692,6 +717,8 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { .default_foreground_color = options.config.foreground, .background_color = null, .default_background_color = options.config.background, + .background_image = options.config.background_image, + .background_image_mode = options.config.background_image_mode, .cursor_color = null, .default_cursor_color = options.config.cursor_color, .cursor_invert = options.config.cursor_invert, @@ -717,6 +744,9 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { .use_display_p3 = options.config.colorspace == .@"display-p3", .use_linear_blending = options.config.blending.isLinear(), .use_linear_correction = options.config.blending == .@"linear-corrected", + .use_experimental_linear_correction = options.config.blending == .@"linear-corrected", + .has_bg_image = (options.config.background_image.value != null), + .bg_image_opacity = options.config.background_image_opacity, }, // Fonts @@ -1143,6 +1173,17 @@ pub fn updateFrame( try self.prepKittyGraphics(state.terminal); } + if (self.current_background_image == null and + self.background_image.value != null) + { + self.prepBackgroundImage() catch |err| switch (err) { + error.InvalidData => { + log.warn("invalid image data, skipping", .{}); + }, + else => return err, + }; + } + // If we have any terminal dirty flags set then we need to rebuild // the entire screen. This can be optimized in the future. const full_rebuild: bool = rebuild: { @@ -1232,6 +1273,7 @@ pub fn updateFrame( // TODO: Is this expensive? Should we be checking if our // bg color has changed first before doing this work? { + std.log.info("Updating background color to {}", .{critical.bg}); const color = graphics.c.CGColorCreate( @ptrCast(self.terminal_colorspace), &[4]f64{ @@ -1284,6 +1326,31 @@ pub fn updateFrame( } } } + + // Check if we need to update our current background image + if (self.current_background_image) |current_background_image| { + switch (current_background_image) { + .ready => {}, + + .pending_gray, + .pending_gray_alpha, + .pending_rgb, + .pending_rgba, + .replace_gray, + .replace_gray_alpha, + .replace_rgb, + .replace_rgba, + => try self.current_background_image.?.upload(self.alloc, self.gpu_state.device), + + .unload_pending, + .unload_replace, + .unload_ready, + => { + self.current_background_image.?.deinit(self.alloc); + self.current_background_image = null; + }, + } + } } /// Draw the frame to the screen. @@ -1405,7 +1472,10 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void { ); defer encoder.msgSend(void, objc.sel("endEncoding"), .{}); - // Draw background images first + // Draw background image set by the user first + try self.drawBackgroundImage(encoder, frame); + + // Then draw background images try self.drawImagePlacements(encoder, frame, self.image_placements.items[0..self.image_bg_end]); // Then draw background cells @@ -1608,6 +1678,92 @@ fn drawPostShader( ); } +fn drawBackgroundImage( + self: *Metal, + encoder: objc.Object, + frame: *const FrameState, +) !void { + // If we don't have a background image, just return + const current_background_image = self.current_background_image orelse return; + + // Use our background image shader pipeline + encoder.msgSend( + void, + objc.sel("setRenderPipelineState:"), + .{self.shaders.bg_image_pipeline.value}, + ); + + // Set our uniforms + encoder.msgSend( + void, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, + ); + encoder.msgSend( + void, + objc.sel("setFragmentBuffer:offset:atIndex:"), + .{ frame.uniforms.buffer.value, @as(c_ulong, 0), @as(c_ulong, 1) }, + ); + + // Get the texture + const texture = switch (current_background_image) { + .ready => |t| t, + else => { + return; + }, + }; + + // Create our vertex buffer, which is always exactly one item. + const Buffer = mtl_buffer.Buffer(mtl_shaders.BgImage); + var buf = try Buffer.initFill(self.gpu_state.device, &.{.{ + .terminal_size = .{ + @as(f32, @floatFromInt(self.size.terminal().width)), + @as(f32, @floatFromInt(self.size.terminal().height)), + }, + .mode = self.background_image_mode, + }}); + defer buf.deinit(); + + // Set our buffer + encoder.msgSend( + void, + objc.sel("setVertexBuffer:offset:atIndex:"), + .{ buf.buffer.value, @as(c_ulong, 0), @as(c_ulong, 0) }, + ); + + // Set our texture + encoder.msgSend( + void, + objc.sel("setVertexTexture:atIndex:"), + .{ + texture.value, + @as(c_ulong, 0), + }, + ); + encoder.msgSend( + void, + objc.sel("setFragmentTexture:atIndex:"), + .{ + texture.value, + @as(c_ulong, 0), + }, + ); + + // Draw! + encoder.msgSend( + void, + objc.sel("drawIndexedPrimitives:indexCount:indexType:indexBuffer:indexBufferOffset:instanceCount:"), + .{ + @intFromEnum(mtl.MTLPrimitiveType.triangle), + @as(c_ulong, 6), + @intFromEnum(mtl.MTLIndexType.uint16), + self.gpu_state.instance.buffer.value, + @as(c_ulong, 0), + @as(c_ulong, 1), + }, + ); +} + fn drawImagePlacements( self: *Metal, encoder: objc.Object, @@ -2118,6 +2274,82 @@ fn prepKittyImage( gop.value_ptr.transmit_time = image.transmit_time; } +/// Prepares the current background image for upload +pub fn prepBackgroundImage(self: *Metal) !void { + // If the user doesn't have a background image, do nothing... + const path = self.background_image.value orelse return; + + // Read the file content + const file_content = try self.readImageContent(path); + defer self.alloc.free(file_content); + + // Decode the image + const decoded_image: wuffs.ImageData = blk: { + // Extract the file extension + const ext = std.fs.path.extension(path); + const ext_lower = try std.ascii.allocLowerString(self.alloc, ext); + defer self.alloc.free(ext_lower); + + // Match based on extension + if (std.mem.eql(u8, ext_lower, ".png")) { + break :blk try wuffs.png.decode(self.alloc, file_content); + } else if (std.mem.eql(u8, ext_lower, ".jpg") or std.mem.eql(u8, ext_lower, ".jpeg")) { + break :blk try wuffs.jpeg.decode(self.alloc, file_content); + } else { + log.warn("unsupported image format: {s}", .{ext}); + return error.InvalidData; + } + }; + defer self.alloc.free(decoded_image.data); + + // Copy the data into the pending state + const data = try self.alloc.dupe(u8, decoded_image.data); + errdefer self.alloc.free(data); + const pending: Image.Pending = .{ + .width = decoded_image.width, + .height = decoded_image.height, + .data = data.ptr, + }; + + // Store the image + self.current_background_image = .{ .pending_rgba = pending }; +} + +/// Reads the content of the given image path and returns it +pub fn readImageContent(self: *Metal, path: []const u8) ![]u8 { + assert(std.fs.path.isAbsolute(path)); + // Open the file + var file = std.fs.openFileAbsolute(path, .{}) catch |err| { + log.warn("failed to open file: {}", .{err}); + return error.InvalidData; + }; + defer file.close(); + + // File must be a regular file + if (file.stat()) |stat| { + if (stat.kind != .file) { + log.warn("file is not a regular file kind={}", .{stat.kind}); + return error.InvalidData; + } + } else |err| { + log.warn("failed to stat file: {}", .{err}); + return error.InvalidData; + } + + var buf_reader = std.io.bufferedReader(file.reader()); + const reader = buf_reader.reader(); + + // Read the file + var managed = std.ArrayList(u8).init(self.alloc); + errdefer managed.deinit(); + reader.readAllArrayList(&managed, max_image_size) catch |err| { + log.warn("failed to read file: {}", .{err}); + return error.InvalidData; + }; + + return managed.toOwnedSlice(); +} + /// Update the configuration. pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void { // We always redo the font shaper in case font features changed. We @@ -2152,6 +2384,15 @@ pub fn changeConfig(self: *Metal, config: *DerivedConfig) !void { self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null; self.cursor_invert = config.cursor_invert; + // Reset current background image + self.background_image = config.background_image; + self.uniforms.has_bg_image = (config.background_image.value != null); + self.uniforms.bg_image_opacity = config.background_image_opacity; + self.background_image_mode = config.background_image_mode; + if (self.current_background_image) |*img| { + img.markForUnload(); + } + // Update our layer's opaqueness and display sync in case they changed. { // We use a CATransaction so that Core Animation knows that we @@ -2288,6 +2529,9 @@ pub fn setScreenSize( .use_display_p3 = old.use_display_p3, .use_linear_blending = old.use_linear_blending, .use_linear_correction = old.use_linear_correction, + .use_experimental_linear_correction = old.use_experimental_linear_correction, + .has_bg_image = old.has_bg_image, + .bg_image_opacity = old.bg_image_opacity, }; // Reset our cell contents if our grid size has changed. @@ -2709,7 +2953,7 @@ fn rebuildCells( const bg_alpha: u8 = bg_alpha: { const default: u8 = 255; - if (self.config.background_opacity >= 1) break :bg_alpha default; + if (self.current_background_image == null and self.config.background_opacity >= 1) break :bg_alpha default; // Cells that are selected should be fully opaque. if (selected) break :bg_alpha default; @@ -2722,6 +2966,11 @@ fn rebuildCells( break :bg_alpha default; } + // If we have a background image, use the configured background image opacity. + if (self.current_background_image != null) { + break :bg_alpha @intFromFloat(@round((1 - self.config.background_image_opacity) * 255.0)); + } + // Otherwise, we use the configured background opacity. break :bg_alpha @intFromFloat(@round(self.config.background_opacity * 255.0)); }; diff --git a/src/renderer/metal/shaders.zig b/src/renderer/metal/shaders.zig index 8fa170bf2..f9322bfe5 100644 --- a/src/renderer/metal/shaders.zig +++ b/src/renderer/metal/shaders.zig @@ -4,6 +4,7 @@ const assert = std.debug.assert; const macos = @import("macos"); const objc = @import("objc"); const math = @import("../../math.zig"); +const configpkg = @import("../../config.zig"); const mtl = @import("api.zig"); @@ -24,6 +25,9 @@ pub const Shaders = struct { /// like the Kitty image protocol. image_pipeline: objc.Object, + /// Background image shader for images set by the user + bg_image_pipeline: objc.Object, + /// 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. @@ -52,6 +56,9 @@ pub const Shaders = struct { const image_pipeline = try initImagePipeline(device, library, pixel_format); errdefer image_pipeline.msgSend(void, objc.sel("release"), .{}); + const bg_image_pipeline = try initBgImagePipeline(device, library, pixel_format); + errdefer bg_image_pipeline.msgSend(void, objc.sel("release"), .{}); + const post_pipelines: []const objc.Object = initPostPipelines( alloc, device, @@ -75,6 +82,7 @@ pub const Shaders = struct { .cell_text_pipeline = cell_text_pipeline, .cell_bg_pipeline = cell_bg_pipeline, .image_pipeline = image_pipeline, + .bg_image_pipeline = bg_image_pipeline, .post_pipelines = post_pipelines, }; } @@ -84,6 +92,7 @@ pub const Shaders = struct { self.cell_text_pipeline.msgSend(void, objc.sel("release"), .{}); self.cell_bg_pipeline.msgSend(void, objc.sel("release"), .{}); self.image_pipeline.msgSend(void, objc.sel("release"), .{}); + self.bg_image_pipeline.msgSend(void, objc.sel("release"), .{}); self.library.msgSend(void, objc.sel("release"), .{}); // Release our postprocess shaders @@ -104,6 +113,12 @@ pub const Image = extern struct { dest_size: [2]f32, }; +/// Single parameter for the background image shader. See shader for field details. +pub const BgImage = extern struct { + terminal_size: [2]f32, + mode: configpkg.BackgroundImageMode, +}; + /// The uniforms that are passed to the terminal cell shader. pub const Uniforms = extern struct { // Note: all of the explicit aligmnments are copied from the @@ -160,6 +175,12 @@ pub const Uniforms = extern struct { /// (thickness) to gamma-incorrect blending. use_linear_correction: bool align(1) = false, + /// Indicates if the user has set a background image. + has_bg_image: bool align(1), + + /// The opacity of the background image. + bg_image_opacity: f32 align(4), + const PaddingExtend = packed struct(u8) { left: bool = false, right: bool = false, @@ -680,6 +701,119 @@ fn initImagePipeline( return pipeline_state; } +fn initBgImagePipeline( + device: objc.Object, + library: objc.Object, + pixel_format: mtl.MTLPixelFormat, +) !objc.Object { + // Get our vertex and fragment functions + const func_vert = func_vert: { + const str = try macos.foundation.String.createWithBytes( + "bg_image_vertex", + .utf8, + false, + ); + defer str.release(); + + const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); + break :func_vert objc.Object.fromId(ptr.?); + }; + const func_frag = func_frag: { + const str = try macos.foundation.String.createWithBytes( + "bg_image_fragment", + .utf8, + false, + ); + defer str.release(); + + const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str}); + break :func_frag objc.Object.fromId(ptr.?); + }; + defer func_vert.msgSend(void, objc.sel("release"), .{}); + defer func_frag.msgSend(void, objc.sel("release"), .{}); + + // Create the vertex descriptor. The vertex descriptor describes the + // data layout of the vertex inputs. We use indexed (or "instanced") + // rendering, so this makes it so that each instance gets a single + // Image as input. + const vertex_desc = vertex_desc: { + const 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; + }; + + // Our attributes are the fields of the input + const attrs = objc.Object.fromId(desc.getProperty(?*anyopaque, "attributes")); + autoAttribute(BgImage, attrs); + + // The layout describes how and when we fetch the next vertex input. + const layouts = objc.Object.fromId(desc.getProperty(?*anyopaque, "layouts")); + { + const layout = layouts.msgSend( + objc.Object, + objc.sel("objectAtIndexedSubscript:"), + .{@as(c_ulong, 0)}, + ); + + // Access each Image per instance, not per vertex. + layout.setProperty("stepFunction", @intFromEnum(mtl.MTLVertexStepFunction.per_instance)); + layout.setProperty("stride", @as(c_ulong, @sizeOf(BgImage))); + } + + break :vertex_desc desc; + }; + defer vertex_desc.msgSend(void, objc.sel("release"), .{}); + + // 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"), .{}); + + // Set our properties + desc.setProperty("vertexFunction", func_vert); + desc.setProperty("fragmentFunction", func_frag); + desc.setProperty("vertexDescriptor", vertex_desc); + + // Set our color attachment + const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments")); + { + const attachment = attachments.msgSend( + objc.Object, + objc.sel("objectAtIndexedSubscript:"), + .{@as(c_ulong, 0)}, + ); + + attachment.setProperty("pixelFormat", @intFromEnum(pixel_format)); + + // Blending. This is required so that our text we render on top + // of our drawable properly blends into the bg. + attachment.setProperty("blendingEnabled", true); + 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 = device.msgSend( + objc.Object, + objc.sel("newRenderPipelineStateWithDescriptor:error:"), + .{ desc, &err }, + ); + try checkError(err); + + return pipeline_state; +} + fn autoAttribute(T: type, attrs: objc.Object) void { inline for (@typeInfo(T).@"struct".fields, 0..) |field, i| { const offset = @offsetOf(T, field.name); diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index 5b3875221..b124ff260 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -23,6 +23,9 @@ struct Uniforms { bool use_display_p3; bool use_linear_blending; bool use_linear_correction; + bool use_experimental_linear_correction; + bool has_bg_image; + float bg_image_opacity; }; //------------------------------------------------------------------- @@ -237,9 +240,14 @@ vertex CellBgVertexOut cell_bg_vertex( position.zw = 1.0; out.position = position; + uchar4 bg_color = uniforms.bg_color; + if (uniforms.has_bg_image) { + bg_color.a = uchar((1.0f - uniforms.bg_image_opacity) * 255); + } + // Convert the background color to Display P3 out.bg_color = load_color( - uniforms.bg_color, + bg_color, uniforms.use_display_p3, uniforms.use_linear_blending ); @@ -677,3 +685,122 @@ fragment float4 image_fragment( return rgba; } +//------------------------------------------------------------------- +// Background Image Shader +//------------------------------------------------------------------- +#pragma mark - BG Image Shader + +enum BgImageMode : uint8_t { + MODE_ZOOMED = 0u, + MODE_STRETCHED = 1u, + MODE_TILED = 2u, + MODE_CENTERED = 3u, + MODE_UPPER_LEFT = 4u, + MODE_UPPER_RIGHT = 5u, + MODE_LOWER_LEFT = 6u, + MODE_LOWER_RIGHT = 7u, +}; + +struct BgImageVertexIn { + float2 terminal_size [[attribute(0)]]; + uint8_t mode [[attribute(1)]]; +}; + +struct BgImageVertexOut { + float4 position [[position]]; + float2 tex_coord; +}; + +vertex BgImageVertexOut bg_image_vertex( + uint vid [[vertex_id]], + BgImageVertexIn in [[stage_in]], + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + BgImageVertexOut out; + + // Calculate the position of the image + float2 position; + position.x = (vid == 0 || vid == 1) ? 1.0 : 0.0; + position.y = (vid == 0 || vid == 3) ? 0.0 : 1.0; + + // Get the size of the image + float2 image_size = float2(image.get_width(), image.get_height()); + + // Handles the scale of the image relative to the terminal size + float2 scale = float2(1.0, 1.0); + + switch (in.mode) { + case MODE_ZOOMED: { + // Scale to fit the terminal size + float2 aspect_ratio = float2( + in.terminal_size.x / in.terminal_size.y, + image_size.x / image_size.y + ); + if (aspect_ratio.x > aspect_ratio.y) { + scale.x = aspect_ratio.y / aspect_ratio.x; + } else { + scale.y = aspect_ratio.x / aspect_ratio.y; + } + break; + } + case MODE_CENTERED: + case MODE_UPPER_LEFT: + case MODE_UPPER_RIGHT: + case MODE_LOWER_LEFT: + case MODE_LOWER_RIGHT: { + // Scale to match the actual size of the image + scale.x = image_size.x / in.terminal_size.x; + scale.y = image_size.y / in.terminal_size.y; + break; + } + case MODE_STRETCHED: + case MODE_TILED: + // No adjustments needed + break; + } + + float2 final_image_size = in.terminal_size * position * scale; + + float2 offset = float2(0.0, 0.0); + switch (in.mode) { + case MODE_ZOOMED: + case MODE_STRETCHED: + case MODE_TILED: + case MODE_CENTERED: + offset = (in.terminal_size * (1.0 - scale)) / 2.0; + break; + case MODE_UPPER_LEFT: + offset = float2(0.0, 0.0); + break; + case MODE_UPPER_RIGHT: + offset = float2(in.terminal_size.x - image_size.x, 0.0); + break; + case MODE_LOWER_LEFT: + offset = float2(0.0, in.terminal_size.y - image_size.y); + break; + case MODE_LOWER_RIGHT: + offset = float2(in.terminal_size.x - image_size.x, in.terminal_size.y - image_size.y); + break; + } + + out.position = uniforms.projection_matrix * float4(final_image_size + offset, 0.0, 1.0); + out.tex_coord = position; + if (in.mode == MODE_TILED) { + out.tex_coord = position * in.terminal_size / image_size; + } + + return out; +} + +fragment float4 bg_image_fragment( + BgImageVertexOut in [[stage_in]], + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + constexpr sampler textureSampler(address::repeat, filter::linear); + float2 norm_coord = fract(in.tex_coord); + float4 color = float4(image.sample(textureSampler, norm_coord)) / 255.0f; + + return float4(color.rgb * color.a * uniforms.bg_image_opacity, color.a * uniforms.bg_image_opacity); +}