diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 6a6adb494..9c2defd1b 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -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 = ""; }; 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 +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 */, 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. 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 70be1a96b..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"); @@ -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, 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/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( 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(); +}