From cb0e60c3e5d3c966884859a4b35a575572c33e48 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Sep 2025 07:20:16 -0700 Subject: [PATCH 1/4] renderer/metal: provide MTLTextureUsage render target for custom shaders This fixes a Metal validation error in Xcode when using custom shaders. I suspect this is one part of custom shaders not working properly on Intel macs (probably anything with a discrete GPU). This happens to work on Apple Silicon but this is undefined behavior and we're just getting lucky. There is one more issue I'm chasing down that I think is also still blocking custom shaders working on Intel macs. --- src/renderer/Metal.zig | 17 +++++++++++++++++ src/renderer/metal/Texture.zig | 2 ++ 2 files changed, 19 insertions(+) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 70be1a96b..b42b83276 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -273,6 +273,15 @@ pub inline fn textureOptions(self: Metal) Texture.Options { .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, + }, }; } @@ -311,6 +320,10 @@ pub inline fn imageTextureOptions( .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, + }, }; } @@ -334,6 +347,10 @@ pub fn initAtlasTexture( .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, diff --git a/src/renderer/metal/Texture.zig b/src/renderer/metal/Texture.zig index 5e6ef78d0..cde50e8de 100644 --- a/src/renderer/metal/Texture.zig +++ b/src/renderer/metal/Texture.zig @@ -18,6 +18,7 @@ pub const Options = struct { device: objc.Object, pixel_format: mtl.MTLPixelFormat, resource_options: mtl.MTLResourceOptions, + usage: mtl.MTLTextureUsage, }; /// The underlying MTLTexture Object. @@ -57,6 +58,7 @@ pub fn init( 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( From a45368161507343c5ce540c2e2bb9ba41bc46e91 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Sep 2025 08:57:47 -0700 Subject: [PATCH 2/4] renderer: create explicit sampler state for custom shaders The GLSL to MSL conversion process uses a passed-in sampler state for the `iChannel0` parameter and we weren't providing it. This magically worked on Apple Silicon for unknown reasons but failed on Intel GPUs. In normal, hand-written MSL, we'd explicitly create the sampler state as a normal variable (we do this in `shaders.metal` already!), but the Shadertoy conversion stuff doesn't do this, probably because the exact sampler parameters can't be safely known. This fixes a Metal validation error when using custom shaders: ``` -[MTLDebugRenderCommandEncoder validateCommonDrawErrors:]:5970: failed assertion `Draw Errors Validation Fragment Function(main0): missing Sampler binding at index 0 for iChannel0Smplr[0]. ``` --- pkg/opengl/Sampler.zig | 43 +++++++++++++++++++ pkg/opengl/main.zig | 1 + src/renderer/Metal.zig | 13 ++++++ src/renderer/OpenGL.zig | 12 ++++++ src/renderer/generic.zig | 20 +++++++++ src/renderer/metal/RenderPass.zig | 13 ++++++ src/renderer/metal/Sampler.zig | 66 ++++++++++++++++++++++++++++++ src/renderer/opengl/RenderPass.zig | 7 ++++ src/renderer/opengl/Sampler.zig | 47 +++++++++++++++++++++ 9 files changed, 222 insertions(+) create mode 100644 pkg/opengl/Sampler.zig create mode 100644 src/renderer/metal/Sampler.zig create mode 100644 src/renderer/opengl/Sampler.zig diff --git a/pkg/opengl/Sampler.zig b/pkg/opengl/Sampler.zig new file mode 100644 index 000000000..f5c15699f --- /dev/null +++ b/pkg/opengl/Sampler.zig @@ -0,0 +1,43 @@ +const Sampler = @This(); + +const std = @import("std"); +const c = @import("c.zig").c; +const errors = @import("errors.zig"); +const glad = @import("glad.zig"); +const Texture = @import("Texture.zig"); + +id: c.GLuint, + +/// Create a single sampler. +pub fn create() errors.Error!Sampler { + var id: c.GLuint = undefined; + glad.context.GenSamplers.?(1, &id); + try errors.getError(); + return .{ .id = id }; +} + +/// glBindSampler +pub fn bind(v: Sampler, index: c_uint) !void { + glad.context.BindSampler.?(index, v.id); + try errors.getError(); +} + +pub fn parameter( + self: Sampler, + name: Texture.Parameter, + value: anytype, +) errors.Error!void { + switch (@TypeOf(value)) { + c.GLint => glad.context.SamplerParameteri.?( + self.id, + @intFromEnum(name), + value, + ), + else => unreachable, + } + try errors.getError(); +} + +pub fn destroy(v: Sampler) void { + glad.context.DeleteSamplers.?(1, &v.id); +} diff --git a/pkg/opengl/main.zig b/pkg/opengl/main.zig index 7165ad3ab..2f22154c6 100644 --- a/pkg/opengl/main.zig +++ b/pkg/opengl/main.zig @@ -18,6 +18,7 @@ pub const Buffer = @import("Buffer.zig"); pub const Framebuffer = @import("Framebuffer.zig"); pub const Renderbuffer = @import("Renderbuffer.zig"); pub const Program = @import("Program.zig"); +pub const Sampler = @import("Sampler.zig"); pub const Shader = @import("Shader.zig"); pub const Texture = @import("Texture.zig"); pub const VertexArray = @import("VertexArray.zig"); diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index b42b83276..f4201edcc 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -25,6 +25,7 @@ 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"); @@ -285,6 +286,18 @@ pub inline fn textureOptions(self: Metal) Texture.Options { }; } +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. diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index e572806d1..673f79501 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -20,6 +20,7 @@ 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"); @@ -364,6 +365,17 @@ pub inline fn textureOptions(self: OpenGL) Texture.Options { }; } +/// 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. diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index a72acf5c2..fbc8cab99 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -85,6 +85,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { const Target = GraphicsAPI.Target; const Buffer = GraphicsAPI.Buffer; + const Sampler = GraphicsAPI.Sampler; const Texture = GraphicsAPI.Texture; const RenderPass = GraphicsAPI.RenderPass; @@ -428,6 +429,19 @@ pub fn Renderer(comptime GraphicsAPI: type) type { 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); @@ -459,9 +473,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { ); 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, }; } @@ -469,6 +487,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { pub fn deinit(self: *CustomShaderState) void { self.front_texture.deinit(); self.back_texture.deinit(); + self.sampler.deinit(); self.uniforms.deinit(); } @@ -1509,6 +1528,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .pipeline = pipeline, .uniforms = state.uniforms.buffer, .textures = &.{state.back_texture}, + .samplers = &.{state.sampler}, .draw = .{ .type = .triangle, .vertex_count = 3, diff --git a/src/renderer/metal/RenderPass.zig b/src/renderer/metal/RenderPass.zig index e48bc4c00..d42d9fa21 100644 --- a/src/renderer/metal/RenderPass.zig +++ b/src/renderer/metal/RenderPass.zig @@ -9,6 +9,7 @@ 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 Metal = @import("../Metal.zig"); @@ -41,6 +42,9 @@ pub const Step = struct { /// 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. @@ -200,6 +204,15 @@ pub fn step(self: *const Self, s: Step) void { ); }; + // 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, diff --git a/src/renderer/metal/Sampler.zig b/src/renderer/metal/Sampler.zig new file mode 100644 index 000000000..0f4de8848 --- /dev/null +++ b/src/renderer/metal/Sampler.zig @@ -0,0 +1,66 @@ +//! Wrapper for handling samplers. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +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/src/renderer/opengl/RenderPass.zig b/src/renderer/opengl/RenderPass.zig index 0f5bd89e7..7a9365d88 100644 --- a/src/renderer/opengl/RenderPass.zig +++ b/src/renderer/opengl/RenderPass.zig @@ -8,6 +8,7 @@ const builtin = @import("builtin"); const gl = @import("opengl"); const OpenGL = @import("../OpenGL.zig"); +const Sampler = @import("Sampler.zig"); const Target = @import("Target.zig"); const Texture = @import("Texture.zig"); const Pipeline = @import("Pipeline.zig"); @@ -35,6 +36,7 @@ pub const Step = struct { uniforms: ?gl.Buffer = null, buffers: []const ?gl.Buffer = &.{}, textures: []const ?Texture = &.{}, + samplers: []const ?Sampler = &.{}, draw: Draw, /// Describes the draw call for this step. @@ -103,6 +105,11 @@ pub fn step(self: *Self, s: Step) void { _ = 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) { diff --git a/src/renderer/opengl/Sampler.zig b/src/renderer/opengl/Sampler.zig new file mode 100644 index 000000000..98d4b35fe --- /dev/null +++ b/src/renderer/opengl/Sampler.zig @@ -0,0 +1,47 @@ +//! Wrapper for handling samplers. +const Self = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const builtin = @import("builtin"); +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(); +} From 058d6808c10c65b62432834681c6969ec6a4ad0d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Sep 2025 09:53:21 -0700 Subject: [PATCH 3/4] macos: custom progress bar to workaround macOS 26 ProgressView bugs Fixes #8731 The progress view in macOS 26 is broken in ways we can't work around directly. Instead, we must create our own custom progress bar. Luckily, our usage of the progress view is very simple. Amp threads: https://ampcode.com/threads/T-88b550b7-5e0d-4ab9-97d9-36fb63d18f21 https://ampcode.com/threads/T-721d6085-21d5-497d-b6ac-9f203aae0b94 --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + macos/Sources/Ghostty/Ghostty.Action.swift | 13 +- .../Sources/Ghostty/SurfaceProgressBar.swift | 113 ++++++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 57 ++------- 4 files changed, 136 insertions(+), 51 deletions(-) create mode 100644 macos/Sources/Ghostty/SurfaceProgressBar.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 6a6adb494..0f843c7ce 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -143,6 +143,7 @@ A5E408432E047D0B0035FEAC /* CommandPaletteIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */; }; A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; }; A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; }; + A5F9A1F22E7C7301005AFACE /* SurfaceProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -293,6 +294,7 @@ A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = ""; }; A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = ""; }; A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = ""; }; + A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceProgressBar.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -492,6 +494,7 @@ isa = PBXGroup; children = ( A55B7BB729B6F53A0055DE60 /* Package.swift */, + A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */, A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */, A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */, A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */, @@ -892,6 +895,7 @@ A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, + A5F9A1F22E7C7301005AFACE /* SurfaceProgressBar.swift in Sources */, A505D21F2E1B6DE00018808F /* NSWorkspace+Extension.swift in Sources */, A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A5CF66D72D29DDB500139794 /* Ghostty.Event.swift in Sources */, diff --git a/macos/Sources/Ghostty/Ghostty.Action.swift b/macos/Sources/Ghostty/Ghostty.Action.swift index ff265189b..37b1a362d 100644 --- a/macos/Sources/Ghostty/Ghostty.Action.swift +++ b/macos/Sources/Ghostty/Ghostty.Action.swift @@ -99,10 +99,13 @@ extension Ghostty.Action { let state: State let progress: UInt8? - - init(c: ghostty_action_progress_report_s) { - self.state = State(c.state) - self.progress = c.progress >= 0 ? UInt8(c.progress) : nil - } + } +} + +// Putting the initializer in an extension preserves the automatic one. +extension Ghostty.Action.ProgressReport { + init(c: ghostty_action_progress_report_s) { + self.state = State(c.state) + self.progress = c.progress >= 0 ? UInt8(c.progress) : nil } } diff --git a/macos/Sources/Ghostty/SurfaceProgressBar.swift b/macos/Sources/Ghostty/SurfaceProgressBar.swift new file mode 100644 index 000000000..82d26e681 --- /dev/null +++ b/macos/Sources/Ghostty/SurfaceProgressBar.swift @@ -0,0 +1,113 @@ +import SwiftUI + +/// The progress bar to show a surface progress report. We implement this from scratch because the +/// standard ProgressView is broken on macOS 26 and this is simple anyways and gives us a ton of +/// control. +struct SurfaceProgressBar: View { + let report: Ghostty.Action.ProgressReport + + private var color: Color { + switch report.state { + case .error: return .red + case .pause: return .orange + default: return .accentColor + } + } + + private var progress: UInt8? { + // If we have an explicit progress use that. + if let v = report.progress { return v } + + // Otherwise, if we're in the pause state, we act as if we're at 100%. + if report.state == .pause { return 100 } + + return nil + } + + private var accessibilityLabel: String { + switch report.state { + case .error: return "Terminal progress - Error" + case .pause: return "Terminal progress - Paused" + case .indeterminate: return "Terminal progress - In progress" + default: return "Terminal progress" + } + } + + private var accessibilityValue: String { + if let progress { + return "\(progress) percent complete" + } else { + switch report.state { + case .error: return "Operation failed" + case .pause: return "Operation paused at completion" + case .indeterminate: return "Operation in progress" + default: return "Indeterminate progress" + } + } + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + if let progress { + // Determinate progress bar with specific percentage + Rectangle() + .fill(color) + .frame( + width: geometry.size.width * CGFloat(progress) / 100, + height: geometry.size.height + ) + .animation(.easeInOut(duration: 0.2), value: progress) + } else { + // Indeterminate states without specific progress - all use bouncing animation + BouncingProgressBar(color: color) + } + } + } + .frame(height: 2) + .clipped() + .allowsHitTesting(false) + .accessibilityElement(children: .ignore) + .accessibilityAddTraits(.updatesFrequently) + .accessibilityLabel(accessibilityLabel) + .accessibilityValue(accessibilityValue) + } +} + +/// Bouncing progress bar for indeterminate states +private struct BouncingProgressBar: View { + let color: Color + @State private var position: CGFloat = 0 + + private let barWidthRatio: CGFloat = 0.25 + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Rectangle() + .fill(color.opacity(0.3)) + + Rectangle() + .fill(color) + .frame( + width: geometry.size.width * barWidthRatio, + height: geometry.size.height + ) + .offset(x: position * (geometry.size.width * (1 - barWidthRatio))) + } + } + .onAppear { + withAnimation( + .easeInOut(duration: 1.2) + .repeatForever(autoreverses: true) + ) { + position = 1 + } + } + .onDisappear { + position = 0 + } + } +} + + diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 38efef646..25a142d65 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -114,11 +114,17 @@ extension Ghostty { } .ghosttySurfaceView(surfaceView) - // Progress report overlay - if let progressReport = surfaceView.progressReport { - ProgressReportOverlay(report: progressReport) + // Progress report + if let progressReport = surfaceView.progressReport, progressReport.state != .remove { + VStack(spacing: 0) { + SurfaceProgressBar(report: progressReport) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .allowsHitTesting(false) + .transition(.opacity) } - + #if canImport(AppKit) // If we are in the middle of a key sequence, then we show a visual element. We only // support this on macOS currently although in theory we can support mobile with keyboards! @@ -272,48 +278,7 @@ extension Ghostty { } } - // Progress report overlay that shows a progress bar at the top of the terminal - struct ProgressReportOverlay: View { - let report: Action.ProgressReport - - @ViewBuilder - private var progressBar: some View { - if let progress = report.progress { - // Determinate progress bar - ProgressView(value: Double(progress), total: 100) - .progressViewStyle(.linear) - .tint(report.state == .error ? .red : report.state == .pause ? .orange : nil) - .animation(.easeInOut(duration: 0.2), value: progress) - } else { - // Indeterminate states - switch report.state { - case .indeterminate: - ProgressView() - .progressViewStyle(.linear) - case .error: - ProgressView() - .progressViewStyle(.linear) - .tint(.red) - case .pause: - Rectangle().fill(Color.orange) - default: - EmptyView() - } - } - } - - var body: some View { - VStack(spacing: 0) { - progressBar - .scaleEffect(x: 1, y: 0.5, anchor: .center) - .frame(height: 2) - - Spacer() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .allowsHitTesting(false) - } - } + // This is the resize overlay that shows on top of a surface to show the current // size during a resize operation. From 354b62d5ce6cf622c7403eae5ad8ce02d0de09bc Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Sep 2025 10:48:28 -0700 Subject: [PATCH 4/4] macos: add progress bar to iOS target --- macos/Ghostty.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 0f843c7ce..9c2defd1b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -144,6 +144,7 @@ A5E408452E0483FD0035FEAC /* KeybindIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408442E0483F80035FEAC /* KeybindIntent.swift */; }; A5E408472E04852B0035FEAC /* InputIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E408462E0485270035FEAC /* InputIntent.swift */; }; A5F9A1F22E7C7301005AFACE /* SurfaceProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */; }; + A5F9A1F32E7C7D59005AFACE /* SurfaceProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -990,6 +991,7 @@ A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */, A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */, A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */, + A5F9A1F32E7C7D59005AFACE /* SurfaceProgressBar.swift in Sources */, A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */, A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A5D689BE2E654D98002E2346 /* Ghostty.Action.swift in Sources */,