Merge remote-tracking branch 'upstream/main' into jacob/uucode

pull/8757/head
Jacob Sandlund 2025-09-18 14:27:33 -04:00
commit 4fc8faa01e
14 changed files with 379 additions and 51 deletions

View File

@ -143,6 +143,8 @@
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 */; };
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 */; };
@ -293,6 +295,7 @@
A5E408422E047D060035FEAC /* CommandPaletteIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandPaletteIntent.swift; sourceTree = "<group>"; };
A5E408442E0483F80035FEAC /* KeybindIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeybindIntent.swift; sourceTree = "<group>"; };
A5E408462E0485270035FEAC /* InputIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputIntent.swift; sourceTree = "<group>"; };
A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceProgressBar.swift; sourceTree = "<group>"; };
A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = "<group>"; };
C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = "<group>"; };
@ -492,6 +495,7 @@
isa = PBXGroup;
children = (
A55B7BB729B6F53A0055DE60 /* Package.swift */,
A5F9A1F12E7C7301005AFACE /* SurfaceProgressBar.swift */,
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */,
A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */,
A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */,
@ -892,6 +896,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 */,
@ -986,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 */,

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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.

43
pkg/opengl/Sampler.zig Normal file
View File

@ -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);
}

View File

@ -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");

View File

@ -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");
@ -273,6 +274,27 @@ 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,
},
};
}
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,
};
}
@ -311,6 +333,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 +360,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,

View File

@ -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.

View File

@ -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,

View File

@ -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,

View File

@ -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();
}

View File

@ -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(

View File

@ -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) {

View File

@ -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();
}