diff --git a/Sources/SwiftTerm/Mac/MacTerminalView.swift b/Sources/SwiftTerm/Mac/MacTerminalView.swift index 4167a8c0..749ce093 100644 --- a/Sources/SwiftTerm/Mac/MacTerminalView.swift +++ b/Sources/SwiftTerm/Mac/MacTerminalView.swift @@ -98,6 +98,15 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, var cellDimension: CellDimension! var caretView: CaretView! public var terminal: Terminal! + + /// The rendering backend used by this terminal view. + /// Set to a ``MetalTerminalRenderer`` instance to enable GPU-accelerated rendering. + /// Defaults to ``CoreGraphicsRenderer`` if not set before ``setup()`` completes. + public var renderer: TerminalRenderer? { + didSet { + renderer?.setup(view: self) + } + } private var progressBarView: TerminalProgressBarView? private var progressReportTimer: Timer? private var lastProgressValue: UInt8? @@ -175,6 +184,11 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, setupOptions() setupProgressBar() setupFocusNotification() + if renderer == nil { + let cg = CoreGraphicsRenderer() + cg.setup(view: self) + renderer = cg + } } func startDisplayUpdates () @@ -534,7 +548,17 @@ open class TerminalView: NSView, NSTextInputClient, NSUserInterfaceValidations, guard let currentContext = getCurrentGraphicsContext() else { return } - drawTerminalContents (dirtyRect: dirtyRect, context: currentContext, bufferOffset: terminal.displayBuffer.yDisp) + let bufferOffset = terminal.displayBuffer.yDisp + if let renderer { + let dims = CellDimensions( + width: cellDimension.width, + height: cellDimension.height, + descent: CTFontGetDescent(fontSet.normal), + leading: CTFontGetLeading(fontSet.normal)) + renderer.draw(in: currentContext, dirtyRect: dirtyRect, cellDimensions: dims, bufferOffset: bufferOffset) + } else { + drawTerminalContents(dirtyRect: dirtyRect, context: currentContext, bufferOffset: bufferOffset) + } } public override func cursorUpdate(with event: NSEvent) diff --git a/Sources/SwiftTerm/Rendering/CoreGraphicsRenderer.swift b/Sources/SwiftTerm/Rendering/CoreGraphicsRenderer.swift new file mode 100644 index 00000000..8bbea966 --- /dev/null +++ b/Sources/SwiftTerm/Rendering/CoreGraphicsRenderer.swift @@ -0,0 +1,44 @@ +// +// CoreGraphicsRenderer.swift +// +// Default renderer that delegates to the existing CoreGraphics-based +// drawing code in AppleTerminalView. +// +#if os(macOS) || os(iOS) || os(visionOS) +import Foundation +import CoreGraphics + +/// A thin wrapper around the existing CoreGraphics rendering in +/// ``TerminalView``. It delegates ``draw(in:dirtyRect:cellDimensions:bufferOffset:)`` +/// back to the view's ``drawTerminalContents(dirtyRect:context:bufferOffset:)`` method. +public class CoreGraphicsRenderer: TerminalRenderer { + weak var view: TerminalView? + + public init() {} + + public func setup(view: TerminalView) { + self.view = view + } + + public func draw( + in context: CGContext, + dirtyRect: CGRect, + cellDimensions: CellDimensions, + bufferOffset: Int + ) { + view?.drawTerminalContents(dirtyRect: dirtyRect, context: context, bufferOffset: bufferOffset) + } + + public func colorsChanged() { + view?.resetCaches() + } + + public func fontChanged() { + view?.resetCaches() + } + + public func invalidateAll() { + view?.resetCaches() + } +} +#endif diff --git a/Sources/SwiftTerm/Rendering/GlyphAtlas.swift b/Sources/SwiftTerm/Rendering/GlyphAtlas.swift new file mode 100644 index 00000000..0ac44a1d --- /dev/null +++ b/Sources/SwiftTerm/Rendering/GlyphAtlas.swift @@ -0,0 +1,353 @@ +// +// GlyphAtlas.swift +// +// Manages a texture atlas of pre-rendered glyphs for Metal rendering. +// Glyphs are rasterized via CoreText and packed into a single MTLTexture +// so the GPU can render terminal text with minimal draw calls. +// + +#if os(macOS) || os(iOS) || os(visionOS) +import Foundation +import Metal +import CoreText +import CoreGraphics + +// MARK: - Supporting Types + +/// Identifies a font style variant for glyph lookup. +public enum GlyphStyle: UInt8, Hashable { + case normal + case bold + case italic + case boldItalic +} + +/// Hashable key for glyph cache lookup. +public struct GlyphKey: Hashable { + public let codePoint: UInt32 + public let style: GlyphStyle + + public init(codePoint: UInt32, style: GlyphStyle) { + self.codePoint = codePoint + self.style = style + } +} + +/// Atlas position and metrics for a rasterized glyph. +public struct GlyphInfo { + public let atlasX: Int + public let atlasY: Int + public let width: Int + public let height: Int + public let bearingX: Float + public let bearingY: Float + public let isWide: Bool + + public var u0: Float { Float(atlasX) / Float(GlyphAtlas.atlasSize) } + public var v0: Float { Float(atlasY) / Float(GlyphAtlas.atlasSize) } + public var u1: Float { Float(atlasX + width) / Float(GlyphAtlas.atlasSize) } + public var v1: Float { Float(atlasY + height) / Float(GlyphAtlas.atlasSize) } +} + +/// A set of CTFont references for each style variant used by the atlas. +public struct GlyphAtlasFontSet { + public let normal: CTFont + public let bold: CTFont + public let italic: CTFont + public let boldItalic: CTFont + + public init(normal: CTFont, bold: CTFont, italic: CTFont, boldItalic: CTFont) { + self.normal = normal + self.bold = bold + self.italic = italic + self.boldItalic = boldItalic + } + + /// Returns the CTFont for the given style. + public func font(for style: GlyphStyle) -> CTFont { + switch style { + case .normal: return normal + case .bold: return bold + case .italic: return italic + case .boldItalic: return boldItalic + } + } +} + +// MARK: - GlyphAtlas + +/// Pre-renders glyphs into an MTLTexture atlas for efficient GPU text rendering. +public class GlyphAtlas { + /// Atlas texture dimensions (square). + public static let atlasSize = 2048 + + public let device: MTLDevice + public private(set) var atlasTexture: MTLTexture + + /// Cached glyph entries keyed by code point + style. + public private(set) var glyphMap: [GlyphKey: GlyphInfo] = [:] + + // Shelf-packing state + private var currentX: Int = 0 + private var currentY: Int = 0 + private var rowHeight: Int = 0 + + /// Terminal cell dimensions in pixels. + public let cellWidth: Int + public let cellHeight: Int + + /// Font set used for rasterization. + public var fonts: GlyphAtlasFontSet + + /// Backing scale factor for HiDPI rasterization. + public let rasterScale: CGFloat + + // MARK: - Initializer + + /// Creates a new glyph atlas backed by an `.r8Unorm` Metal texture. + /// + /// - Parameters: + /// - device: The Metal device to create the texture on. + /// - cellWidth: Width of a single terminal cell in backing pixels. + /// - cellHeight: Height of a single terminal cell in backing pixels. + /// - fonts: The font set containing normal, bold, italic, and boldItalic variants. + /// - scale: Backing scale factor (e.g. 2.0 on Retina). The CGContext is scaled + /// so fonts render with full HiDPI detail. + public init(device: MTLDevice, cellWidth: Int, cellHeight: Int, fonts: GlyphAtlasFontSet, scale: CGFloat = 1) { + self.device = device + self.cellWidth = cellWidth + self.cellHeight = cellHeight + self.fonts = fonts + self.rasterScale = scale + + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .r8Unorm, + width: GlyphAtlas.atlasSize, + height: GlyphAtlas.atlasSize, + mipmapped: false + ) + descriptor.usage = [.shaderRead] + descriptor.storageMode = .managed + + guard let texture = device.makeTexture(descriptor: descriptor) else { + fatalError("GlyphAtlas: failed to create \(GlyphAtlas.atlasSize)×\(GlyphAtlas.atlasSize) atlas texture") + } + self.atlasTexture = texture + } + + // MARK: - Public API + + /// Returns the cached `GlyphInfo` for the given code point and style, rasterizing + /// and uploading the glyph to the atlas on a cache miss. + /// + /// - Parameters: + /// - codePoint: Unicode code point to render. + /// - style: Font style variant. + /// - Returns: The glyph's atlas position and metrics, or `nil` if the atlas is full. + @discardableResult + public func getOrCreate(codePoint: UInt32, style: GlyphStyle) -> GlyphInfo? { + let key = GlyphKey(codePoint: codePoint, style: style) + if let existing = glyphMap[key] { + return existing + } + + let font = fonts.font(for: style) + guard let (pixelData, rasterWidth, rasterHeight, bearingX, bearingY) = rasterize(codePoint: codePoint, font: font) else { + return nil + } + + let wide = isWideCodePoint(codePoint) + + guard let (atlasX, atlasY) = pack(width: rasterWidth, height: rasterHeight) else { + return nil // atlas full + } + + upload(pixelData: pixelData, width: rasterWidth, height: rasterHeight, toX: atlasX, toY: atlasY) + + let info = GlyphInfo( + atlasX: atlasX, + atlasY: atlasY, + width: rasterWidth, + height: rasterHeight, + bearingX: bearingX, + bearingY: bearingY, + isWide: wide + ) + glyphMap[key] = info + return info + } + + /// Clears all cached glyphs and resets the packing state. + /// Call this when fonts change and glyphs need to be re-rasterized. + public func invalidateAll() { + glyphMap.removeAll() + currentX = 0 + currentY = 0 + rowHeight = 0 + } + + // MARK: - Rasterization + + /// Rasterizes a single glyph into an 8-bit grayscale bitmap. + /// + /// - Parameters: + /// - codePoint: Unicode code point. + /// - font: CTFont to use for rendering. + /// - Returns: Tuple of (pixel data, width, height, bearingX, bearingY) or `nil` on failure. + private func rasterize(codePoint: UInt32, font: CTFont) -> (Data, Int, Int, Float, Float)? { + guard let scalar = Unicode.Scalar(codePoint) else { return nil } + + // Map code point to glyph + var characters = [UniChar]() + for utf16Unit in String(scalar).utf16 { + characters.append(utf16Unit) + } + var glyphs = [CGGlyph](repeating: 0, count: characters.count) + guard CTFontGetGlyphsForCharacters(font, &characters, &glyphs, characters.count) else { + return nil + } + + // Determine glyph bounding box for metrics + var boundingRect = CGRect.zero + CTFontGetBoundingRectsForGlyphs(font, .default, &glyphs, &boundingRect, 1) + + let wide = isWideCodePoint(codePoint) + let rasterWidth = wide ? cellWidth * 2 : cellWidth + let rasterHeight = cellHeight + + guard rasterWidth > 0 && rasterHeight > 0 else { return nil } + + let bytesPerRow = rasterWidth + var pixelData = Data(count: rasterHeight * bytesPerRow) + + let success = pixelData.withUnsafeMutableBytes { rawBuffer -> Bool in + guard let baseAddress = rawBuffer.baseAddress else { return false } + + guard let cgContext = CGContext( + data: baseAddress, + width: rasterWidth, + height: rasterHeight, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { + return false + } + + // Scale context for HiDPI rasterization; font metrics are in logical points + let scale = rasterScale + cgContext.scaleBy(x: scale, y: scale) + + // Disable font smoothing — it creates wide halos in grayscale contexts + // that look washed-out when used as alpha in the shader. + // Keep anti-aliasing on so the atlas captures the full glyph shape; + // the shader applies a binary threshold for sharp edges. + cgContext.setAllowsAntialiasing(true) + cgContext.setShouldAntialias(true) + cgContext.setShouldSmoothFonts(false) + + // Clear to black (transparent in our shader) + cgContext.setFillColor(CGColor(gray: 0, alpha: 1)) + cgContext.fill(CGRect(x: 0, y: 0, width: CGFloat(rasterWidth) / scale, height: CGFloat(rasterHeight) / scale)) + + // Draw glyph in white + cgContext.setFillColor(CGColor(gray: 1, alpha: 1)) + + let ascent = CTFontGetAscent(font) + let descent = CTFontGetDescent(font) + let leading = CTFontGetLeading(font) + let lineHeight = ascent + descent + leading + + // Position baseline so the glyph is vertically centered in the cell + // (coordinates are in logical points since the context is scaled) + let logicalHeight = CGFloat(rasterHeight) / scale + let baselineY = descent + (logicalHeight - lineHeight) / 2.0 + + var position = CGPoint(x: 0, y: baselineY) + CTFontDrawGlyphs(font, &glyphs, &position, 1, cgContext) + + return true + } + + guard success else { return nil } + + // Bearing is (0,0) because glyphs are rasterized into full cell-sized + // rectangles with correct baseline positioning already applied. + return (pixelData, rasterWidth, rasterHeight, 0, 0) + } + + // MARK: - Atlas Packing + + /// Finds space in the atlas for a glyph of the given size using shelf packing. + /// + /// - Parameters: + /// - width: Glyph width in pixels. + /// - height: Glyph height in pixels. + /// - Returns: The (x, y) origin in the atlas, or `nil` if there is no room. + private func pack(width: Int, height: Int) -> (Int, Int)? { + let atlasSize = GlyphAtlas.atlasSize + + // Wrap to next row if this glyph doesn't fit horizontally + if currentX + width > atlasSize { + currentY += rowHeight + currentX = 0 + rowHeight = 0 + } + + // Check vertical overflow + if currentY + height > atlasSize { + return nil + } + + let x = currentX + let y = currentY + + currentX += width + rowHeight = max(rowHeight, height) + + return (x, y) + } + + // MARK: - Texture Upload + + /// Copies rasterized pixel data into the atlas texture. + private func upload(pixelData: Data, width: Int, height: Int, toX x: Int, toY y: Int) { + let region = MTLRegion( + origin: MTLOrigin(x: x, y: y, z: 0), + size: MTLSize(width: width, height: height, depth: 1) + ) + pixelData.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + atlasTexture.replace( + region: region, + mipmapLevel: 0, + withBytes: baseAddress, + bytesPerRow: width + ) + } + } + + // MARK: - Helpers + + /// Heuristic for detecting wide (double-width) characters such as CJK ideographs. + private func isWideCodePoint(_ codePoint: UInt32) -> Bool { + // CJK Unified Ideographs + if (0x4E00...0x9FFF).contains(codePoint) { return true } + // CJK Unified Ideographs Extension A + if (0x3400...0x4DBF).contains(codePoint) { return true } + // CJK Compatibility Ideographs + if (0xF900...0xFAFF).contains(codePoint) { return true } + // Fullwidth Forms + if (0xFF01...0xFF60).contains(codePoint) { return true } + if (0xFFE0...0xFFE6).contains(codePoint) { return true } + // CJK Unified Ideographs Extension B–F + if (0x20000...0x2FA1F).contains(codePoint) { return true } + // Hangul Syllables + if (0xAC00...0xD7AF).contains(codePoint) { return true } + // CJK Radicals, Kangxi Radicals, CJK Symbols + if (0x2E80...0x303F).contains(codePoint) { return true } + return false + } +} +#endif diff --git a/Sources/SwiftTerm/Rendering/MetalTerminalRenderer.swift b/Sources/SwiftTerm/Rendering/MetalTerminalRenderer.swift new file mode 100644 index 00000000..545fa68f --- /dev/null +++ b/Sources/SwiftTerm/Rendering/MetalTerminalRenderer.swift @@ -0,0 +1,1046 @@ +// +// MetalTerminalRenderer.swift +// +// GPU-accelerated terminal renderer using Metal. +// Renders terminal cells in two passes: background quads, then text glyphs. +// Shader source is compiled at runtime for SPM compatibility. +// + +#if os(macOS) +import Metal +import MetalKit +import QuartzCore +import CoreText +import AppKit + +// MARK: - GPU Data Structures + +/// Per-cell data sent to the GPU. Must match the CellData struct in the shader. +struct CellData { + var glyphIndex: UInt16 = 0 + var fgR: UInt8 = 255, fgG: UInt8 = 255, fgB: UInt8 = 255, fgA: UInt8 = 255 + var bgR: UInt8 = 0, bgG: UInt8 = 0, bgB: UInt8 = 0, bgA: UInt8 = 255 + var flags: UInt16 = 0 + var padding: UInt16 = 0 +} + +/// Per-glyph entry in the glyph lookup buffer. Must match GlyphEntry in the shader. +struct GlyphEntryData { + var uvRect: SIMD4 = .zero // u0, v0, u1, v1 + var bearing: SIMD2 = .zero // bearingX, bearingY + var size: SIMD2 = .zero // glyph width, height in pixels +} + +/// Uniform data for the shader. Must match Uniforms in the shader. +struct Uniforms { + var viewportSize: SIMD2 = .zero + var cellSize: SIMD2 = .zero + var atlasSize: SIMD2 = .zero + var cols: UInt32 = 0 + var rows: UInt32 = 0 + var time: Float = 0 + var blinkOn: UInt32 = 1 + var scrollY: Float = 0 // Viewport scroll offset in pixels (for smooth scrolling) + var backingScale: Float = 1 // Screen backing scale factor (e.g. 2.0 on Retina) + var _pad: Float = 0 // Align to 16 bytes +} + +/// Per-image-quad data sent to the GPU for image rendering. +struct ImageQuadData { + var position: SIMD2 = .zero // top-left pixel position + var size: SIMD2 = .zero // size in pixels +} + +// MARK: - MetalTerminalRenderer + +/// GPU-accelerated terminal renderer using Metal. +/// +/// Renders the terminal in four instanced draw passes: +/// 1. **Background pass** — draws colored quads for each cell's background +/// 2. **Text pass** — draws glyph alpha from the atlas, tinted with foreground color +/// 3. **Decoration pass** — underline, strikethrough, cursor decorations +/// 4. **Image pass** — draws Sixel/Kitty images as textured quads +public class MetalTerminalRenderer: TerminalRenderer { + + // MARK: - Metal Core + + let device: MTLDevice + let commandQueue: MTLCommandQueue + var bgPipelineState: MTLRenderPipelineState + var textPipelineState: MTLRenderPipelineState + var decoPipelineState: MTLRenderPipelineState + var imagePipelineState: MTLRenderPipelineState + + // MARK: - Buffers + + var cellBuffer: MTLBuffer? + var glyphEntryBuffer: MTLBuffer? + var uniformBuffer: MTLBuffer + + // MARK: - Atlas & View + + var glyphAtlas: GlyphAtlas? + weak var terminalView: TerminalView? + var metalLayer: CAMetalLayer? + + // MARK: - State + + var cols: Int = 0 + var rows: Int = 0 + var cellDims: CellDimensions = CellDimensions(width: 8, height: 16, descent: 3, leading: 1) + /// Cell dimensions used to build the current glyph atlas (in logical pixels). + private var atlasCellWidth: CGFloat = 0 + private var atlasCellHeight: CGFloat = 0 + var viewportSize: CGSize = .zero + /// Whether the terminal is scrolled to the bottom (auto-scroll active). + private(set) var isScrolledToBottom: Bool = true + + /// Blink state for blinking text attribute (SGR 5) and cursor blink. + private var blinkOn: Bool = true + private var blinkTimer: Timer? + + /// Map from GlyphKey to glyph entry index in the GPU buffer. + private var glyphIndexMap: [GlyphKey: UInt16] = [:] + /// Ordered glyph entries for the GPU buffer. + private var glyphEntries: [GlyphEntryData] = [] + /// Whether the glyph entry buffer needs updating. + private var glyphBufferDirty = true + + // MARK: - Image Cache + + /// Cache of MTLTextures keyed by the identity of the source TerminalImage object. + private var imageTextureCache: [ObjectIdentifier: MTLTexture] = [:] + /// Image quads collected during cell buffer update, rendered in the image pass. + private var pendingImageQuads: [(quad: ImageQuadData, texture: MTLTexture)] = [] + + // MARK: - Availability + + /// Returns `true` if a Metal device is available on this system. + public static var isAvailable: Bool { + return MTLCreateSystemDefaultDevice() != nil + } + + // MARK: - Initialization + + /// Creates a new Metal terminal renderer. + /// + /// - Parameter device: The Metal device to use. Defaults to the system default device. + /// - Throws: `fatalError` if Metal is not available or pipeline creation fails. + public init(device: MTLDevice? = nil) { + guard let dev = device ?? MTLCreateSystemDefaultDevice() else { + fatalError("MetalTerminalRenderer: Metal is not available on this system") + } + self.device = dev + + guard let queue = dev.makeCommandQueue() else { + fatalError("MetalTerminalRenderer: failed to create command queue") + } + self.commandQueue = queue + + // Compile shaders from embedded source + let library: MTLLibrary + do { + library = try dev.makeLibrary(source: MetalTerminalRenderer.shaderSource, options: nil) + } catch { + fatalError("MetalTerminalRenderer: failed to compile shaders: \(error)") + } + + // Background pipeline + let bgDesc = MTLRenderPipelineDescriptor() + bgDesc.vertexFunction = library.makeFunction(name: "bgVertex") + bgDesc.fragmentFunction = library.makeFunction(name: "bgFragment") + bgDesc.colorAttachments[0].pixelFormat = .bgra8Unorm + do { + bgPipelineState = try dev.makeRenderPipelineState(descriptor: bgDesc) + } catch { + fatalError("MetalTerminalRenderer: failed to create bg pipeline: \(error)") + } + + // Text pipeline (alpha blending enabled) + let textDesc = MTLRenderPipelineDescriptor() + textDesc.vertexFunction = library.makeFunction(name: "textVertex") + textDesc.fragmentFunction = library.makeFunction(name: "textFragment") + textDesc.colorAttachments[0].pixelFormat = .bgra8Unorm + textDesc.colorAttachments[0].isBlendingEnabled = true + textDesc.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + textDesc.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + textDesc.colorAttachments[0].sourceAlphaBlendFactor = .one + textDesc.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + do { + textPipelineState = try dev.makeRenderPipelineState(descriptor: textDesc) + } catch { + fatalError("MetalTerminalRenderer: failed to create text pipeline: \(error)") + } + + // Decoration pipeline (underline, strikethrough — drawn after text) + let decoDesc = MTLRenderPipelineDescriptor() + decoDesc.vertexFunction = library.makeFunction(name: "decoVertex") + decoDesc.fragmentFunction = library.makeFunction(name: "decoFragment") + decoDesc.colorAttachments[0].pixelFormat = .bgra8Unorm + decoDesc.colorAttachments[0].isBlendingEnabled = true + decoDesc.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + decoDesc.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + decoDesc.colorAttachments[0].sourceAlphaBlendFactor = .one + decoDesc.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + do { + decoPipelineState = try dev.makeRenderPipelineState(descriptor: decoDesc) + } catch { + fatalError("MetalTerminalRenderer: failed to create deco pipeline: \(error)") + } + + // Image pipeline (alpha blending for Sixel/Kitty images) + let imgDesc = MTLRenderPipelineDescriptor() + imgDesc.vertexFunction = library.makeFunction(name: "imageVertex") + imgDesc.fragmentFunction = library.makeFunction(name: "imageFragment") + imgDesc.colorAttachments[0].pixelFormat = .bgra8Unorm + imgDesc.colorAttachments[0].isBlendingEnabled = true + imgDesc.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + imgDesc.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + imgDesc.colorAttachments[0].sourceAlphaBlendFactor = .one + imgDesc.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + do { + imagePipelineState = try dev.makeRenderPipelineState(descriptor: imgDesc) + } catch { + fatalError("MetalTerminalRenderer: failed to create image pipeline: \(error)") + } + + // Uniform buffer + var uniforms = Uniforms() + uniformBuffer = dev.makeBuffer(bytes: &uniforms, length: MemoryLayout.stride, options: .storageModeShared)! + + // Reserve glyph index 0 as "empty / space" glyph + glyphEntries.append(GlyphEntryData()) + } + + // MARK: - TerminalRenderer Protocol + + public func setup(view: TerminalView) { + self.terminalView = view + + // Configure CAMetalLayer on the view's layer + if view.layer == nil { + view.wantsLayer = true + } + guard let viewLayer = view.layer else { + fatalError("MetalTerminalRenderer: failed to get layer from view") + } + setupMetalLayer(on: viewLayer, size: view.bounds.size) + + // Set up glyph atlas with the view's font set + setupGlyphAtlas() + + // Start blink timer for blinking text (SGR 5) and cursor blink + blinkTimer = Timer.scheduledTimer(withTimeInterval: 0.53, repeats: true) { [weak self] _ in + self?.blinkOn.toggle() + self?.terminalView?.setNeedsDisplay(self?.terminalView?.bounds ?? .zero) + } + } + + deinit { + blinkTimer?.invalidate() + } + + public func draw( + in context: CGContext, + dirtyRect: CGRect, + cellDimensions: CellDimensions, + bufferOffset: Int + ) { + self.cellDims = cellDimensions + + guard let view = terminalView else { return } + let terminal = view.terminal! + + // Update Metal layer frame and drawable size to match view + let backingScale = view.window?.backingScaleFactor ?? 1.0 + let drawableWidth = view.bounds.width * backingScale + let drawableHeight = view.bounds.height * backingScale + viewportSize = CGSize(width: drawableWidth, height: drawableHeight) + + if let ml = metalLayer { + CATransaction.begin() + CATransaction.setDisableActions(true) + ml.frame = view.bounds + ml.drawableSize = CGSize(width: drawableWidth, height: drawableHeight) + ml.contentsScale = backingScale + CATransaction.commit() + } + + // Rebuild glyph atlas if backing scale or cell dimensions changed + if backingScale != atlasScale || cellDimensions.width != atlasCellWidth || cellDimensions.height != atlasCellHeight { + glyphIndexMap.removeAll() + glyphEntries.removeAll() + glyphEntries.append(GlyphEntryData()) + glyphBufferDirty = true + setupGlyphAtlas(scale: backingScale) + } + + // Populate cell buffer from terminal state + let displayBuffer = terminal.buffer + let termCols = terminal.cols + let termRows = terminal.rows + self.cols = termCols + self.rows = termRows + + updateCellBuffer(terminal: terminal, displayBuffer: displayBuffer, cols: termCols, rows: termRows, bufferOffset: bufferOffset) + + // Collect image quads from visible buffer lines + collectImageQuads(displayBuffer: displayBuffer, rows: termRows, bufferOffset: bufferOffset, backingScale: backingScale) + + // Update uniforms + updateUniforms(backingScale: Float(backingScale)) + + // Update glyph entry buffer if needed + if glyphBufferDirty { + updateGlyphEntryBuffer() + } + + // Render + renderFrame() + } + + public func resize(cols: Int, rows: Int, cellDimensions: CellDimensions) { + self.cols = cols + self.rows = rows + self.cellDims = cellDimensions + + // Recreate cell buffer for new dimensions + let cellCount = cols * rows + let bufferSize = cellCount * MemoryLayout.stride + cellBuffer = device.makeBuffer(length: bufferSize, options: .storageModeShared) + } + + public func colorsChanged() { + // Colors are read fresh each frame from the terminal buffer + } + + public func fontChanged() { + // Rebuild glyph atlas with new fonts + glyphIndexMap.removeAll() + glyphEntries.removeAll() + glyphEntries.append(GlyphEntryData()) // re-reserve index 0 + glyphBufferDirty = true + imageTextureCache.removeAll() + + setupGlyphAtlas() + } + + public func invalidateAll() { + glyphAtlas?.invalidateAll() + glyphIndexMap.removeAll() + glyphEntries.removeAll() + glyphEntries.append(GlyphEntryData()) // re-reserve index 0 + glyphBufferDirty = true + imageTextureCache.removeAll() + } + + // MARK: - Internal Setup + + private func setupMetalLayer(on layer: CALayer, size: CGSize) { + let metal = CAMetalLayer() + metal.device = device + metal.pixelFormat = .bgra8Unorm + metal.framebufferOnly = true + metal.frame = CGRect(origin: .zero, size: size) + metal.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0 + metal.drawableSize = CGSize( + width: size.width * metal.contentsScale, + height: size.height * metal.contentsScale + ) + + layer.addSublayer(metal) + self.metalLayer = metal + } + + private var atlasScale: CGFloat = 1 + + private func setupGlyphAtlas() { + setupGlyphAtlas(scale: terminalView?.window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0) + } + + private func setupGlyphAtlas(scale: CGFloat) { + guard let view = terminalView else { return } + + let fontSet = view.fontSet + let atlasFonts = GlyphAtlasFontSet( + normal: fontSet.normal, + bold: fontSet.bold, + italic: fontSet.italic, + boldItalic: fontSet.boldItalic + ) + + // Rasterize at backing resolution for 1:1 texel-to-pixel mapping on Retina + atlasScale = scale + atlasCellWidth = cellDims.width + atlasCellHeight = cellDims.height + let cellW = Int(ceil(cellDims.width * scale)) + let cellH = Int(ceil(cellDims.height * scale)) + + glyphAtlas = GlyphAtlas( + device: device, + cellWidth: cellW, + cellHeight: cellH, + fonts: atlasFonts, + scale: scale + ) + } + + // MARK: - Cell Buffer Update + + private func updateCellBuffer(terminal: Terminal, displayBuffer: Buffer, cols: Int, rows: Int, bufferOffset: Int) { + let cellCount = cols * rows + let requiredSize = cellCount * MemoryLayout.stride + + // Track auto-scroll state: at bottom when yDisp == yBase + isScrolledToBottom = (displayBuffer.yDisp == displayBuffer.yBase) + + // Recreate buffer if size changed + if cellBuffer == nil || cellBuffer!.length < requiredSize { + cellBuffer = device.makeBuffer(length: requiredSize, options: .storageModeShared) + } + + guard let buffer = cellBuffer else { return } + let cells = buffer.contents().bindMemory(to: CellData.self, capacity: cellCount) + + // Selection: pre-compute selection background color + var selR: UInt8 = 153, selG: UInt8 = 204, selB: UInt8 = 255 + if let selColor = terminalView?.selectedTextBackgroundColor.usingColorSpace(.sRGB) { + selR = UInt8(selColor.redComponent * 255) + selG = UInt8(selColor.greenComponent * 255) + selB = UInt8(selColor.blueComponent * 255) + } + + // Cursor: determine visibility and screen position + let cursorScreenRow = displayBuffer.yBase + displayBuffer.y - bufferOffset + let cursorCol = displayBuffer.x + let cursorStyle = terminal.options.cursorStyle + let isBlinkCursor: Bool + switch cursorStyle { + case .blinkBlock, .blinkBar, .blinkUnderline: + isBlinkCursor = true + default: + isBlinkCursor = false + } + let cursorBlinkOn = !isBlinkCursor || blinkOn + let showCursor = (terminalView?.hasFocus ?? false) && cursorBlinkOn + && cursorScreenRow >= 0 && cursorScreenRow < rows + + for row in 0..= 0, lineIndex < displayBuffer.lines.count else { + for col in 0.. 0 ? ch.code : 32) // space for empty cells + let style = glyphStyle(from: ch.attribute.style) + cell.glyphIndex = getOrCreateGlyphIndex(codePoint: codePoint, style: style) + } + + // Resolve colors + let (fgR, fgG, fgB) = resolveColor(ch.attribute.fg, isFg: true, terminal: terminal) + let (bgR, bgG, bgB) = resolveColor(ch.attribute.bg, isFg: false, terminal: terminal) + + // Handle inverse + let isInverse = ch.attribute.style.contains(CharacterStyle.inverse) + if isInverse { + cell.fgR = bgR; cell.fgG = bgG; cell.fgB = bgB; cell.fgA = 255 + cell.bgR = fgR; cell.bgG = fgG; cell.bgB = fgB; cell.bgA = 255 + } else { + cell.fgR = fgR; cell.fgG = fgG; cell.fgB = fgB; cell.fgA = 255 + cell.bgR = bgR; cell.bgG = bgG; cell.bgB = bgB; cell.bgA = 255 + } + + // Apply dim/faint (reduce foreground brightness to ~2/3) + if ch.attribute.style.contains(.dim) { + cell.fgR = UInt8(UInt16(cell.fgR) * 2 / 3) + cell.fgG = UInt8(UInt16(cell.fgG) * 2 / 3) + cell.fgB = UInt8(UInt16(cell.fgB) * 2 / 3) + } + + // Selection overlay: apply selection background color + if let range = selRange, range.contains(col) { + cell.bgR = selR; cell.bgG = selG; cell.bgB = selB; cell.bgA = 255 + } + + // Block cursor: invert fg/bg at cursor position + if showCursor && row == cursorScreenRow && col == cursorCol { + if cursorStyle == .blinkBlock || cursorStyle == .steadyBlock { + let tmpR = cell.fgR, tmpG = cell.fgG, tmpB = cell.fgB, tmpA = cell.fgA + cell.fgR = cell.bgR; cell.fgG = cell.bgG; cell.fgB = cell.bgB; cell.fgA = cell.bgA + cell.bgR = tmpR; cell.bgG = tmpG; cell.bgB = tmpB; cell.bgA = tmpA + } + } + + // Pack style flags + var flags: UInt16 = 0 + if ch.attribute.style.contains(CharacterStyle.bold) { flags |= 1 << 0 } + if ch.attribute.style.contains(CharacterStyle.italic) { flags |= 1 << 1 } + if ch.attribute.style.contains(CharacterStyle.underline) { flags |= 1 << 2 } + if ch.attribute.style.contains(CharacterStyle.crossedOut){ flags |= 1 << 3 } + if ch.attribute.style.contains(CharacterStyle.inverse) { flags |= 1 << 4 } + if ch.attribute.style.contains(CharacterStyle.blink) { flags |= 1 << 5 } + + // Cursor: set bar/underline decoration flags + if showCursor && row == cursorScreenRow && col == cursorCol { + switch cursorStyle { + case .blinkBar, .steadyBar: + flags |= 1 << 6 + case .blinkUnderline, .steadyUnderline: + flags |= 1 << 7 + default: + break + } + } + + cell.flags = flags + + cells[idx] = cell + } + } + } + + // MARK: - Color Resolution + + /// Resolves an `Attribute.Color` to RGB uint8 values. + private func resolveColor(_ color: Attribute.Color, isFg: Bool, terminal: Terminal) -> (UInt8, UInt8, UInt8) { + switch color { + case .defaultColor: + let c = isFg ? terminal.foregroundColor : terminal.backgroundColor + return (UInt8(c.red >> 8), UInt8(c.green >> 8), UInt8(c.blue >> 8)) + case .defaultInvertedColor: + let c = isFg ? terminal.backgroundColor : terminal.foregroundColor + return (UInt8(c.red >> 8), UInt8(c.green >> 8), UInt8(c.blue >> 8)) + case .ansi256(let code): + let c = terminal.ansiColors[Int(code)] + return (UInt8(c.red >> 8), UInt8(c.green >> 8), UInt8(c.blue >> 8)) + case .trueColor(let r, let g, let b): + return (r, g, b) + } + } + + // MARK: - Image Collection + + /// Scans visible buffer lines for attached images and builds GPU-ready image quads. + private func collectImageQuads(displayBuffer: Buffer, rows: Int, bufferOffset: Int, backingScale: CGFloat) { + pendingImageQuads.removeAll() + var referencedImages = Set() + let scale = Float(backingScale) + + for row in 0..= 0, lineIndex < displayBuffer.lines.count else { continue } + let line = displayBuffer.lines[lineIndex] + guard let images = line.images else { continue } + + for img in images { + guard let texture = getOrCreateTexture(for: img) else { continue } + let oid = ObjectIdentifier(img as AnyObject) + referencedImages.insert(oid) + + let quad = ImageQuadData( + position: SIMD2(Float(img.col) * Float(cellDims.width) * scale, + Float(row) * Float(cellDims.height) * scale), + size: SIMD2(Float(img.pixelWidth), Float(img.pixelHeight)) + ) + pendingImageQuads.append((quad: quad, texture: texture)) + } + } + + // Evict textures for images no longer visible + let cachedKeys = Array(imageTextureCache.keys) + for key in cachedKeys where !referencedImages.contains(key) { + imageTextureCache.removeValue(forKey: key) + } + } + + /// Returns a cached MTLTexture for the given image, creating one on cache miss. + private func getOrCreateTexture(for image: TerminalImage) -> MTLTexture? { + let oid = ObjectIdentifier(image as AnyObject) + if let cached = imageTextureCache[oid] { + return cached + } + + guard let appleImage = image as? TerminalView.AppleImage else { return nil } + guard let cgImage = appleImage.image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil } + + let width = cgImage.width + let height = cgImage.height + guard width > 0, height > 0 else { return nil } + + let descriptor = MTLTextureDescriptor.texture2DDescriptor( + pixelFormat: .rgba8Unorm, + width: width, + height: height, + mipmapped: false + ) + descriptor.usage = .shaderRead + guard let texture = device.makeTexture(descriptor: descriptor) else { return nil } + + // Render the CGImage into RGBA8 pixel data + let bytesPerRow = 4 * width + let colorSpace = CGColorSpaceCreateDeviceRGB() + var pixelData = [UInt8](repeating: 0, count: bytesPerRow * height) + guard let ctx = CGContext( + data: &pixelData, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { return nil } + + ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + + texture.replace( + region: MTLRegionMake2D(0, 0, width, height), + mipmapLevel: 0, + withBytes: pixelData, + bytesPerRow: bytesPerRow + ) + + imageTextureCache[oid] = texture + return texture + } + + // MARK: - Glyph Management + + /// Maps a `CharacterStyle` to a `GlyphStyle` for atlas lookup. + private func glyphStyle(from style: CharacterStyle) -> GlyphStyle { + let isBold = style.contains(.bold) + let isItalic = style.contains(.italic) + if isBold && isItalic { return .boldItalic } + if isBold { return .bold } + if isItalic { return .italic } + return .normal + } + + /// Returns the GPU glyph index for the given code point and style, + /// rasterizing into the atlas on cache miss. + /// Box drawing (U+2500–U+257F) and block elements (U+2580–U+259F) + /// are rendered through the glyph atlas via CoreText like normal glyphs. + private func getOrCreateGlyphIndex(codePoint: UInt32, style: GlyphStyle) -> UInt16 { + // Space or control characters → index 0 (empty glyph) + if codePoint <= 32 { return 0 } + + let key = GlyphKey(codePoint: codePoint, style: style) + if let existing = glyphIndexMap[key] { + return existing + } + + guard let atlas = glyphAtlas, + let info = atlas.getOrCreate(codePoint: codePoint, style: style) else { + return 0 + } + + let index = UInt16(glyphEntries.count) + let entry = GlyphEntryData( + uvRect: SIMD4(info.u0, info.v0, info.u1, info.v1), + bearing: SIMD2(info.bearingX, info.bearingY), + size: SIMD2(Float(info.width), Float(info.height)) + ) + glyphEntries.append(entry) + glyphIndexMap[key] = index + glyphBufferDirty = true + + return index + } + + // MARK: - Buffer Updates + + private func updateUniforms(backingScale: Float) { + var uniforms = Uniforms() + uniforms.viewportSize = SIMD2(Float(viewportSize.width), Float(viewportSize.height)) + uniforms.cellSize = SIMD2(Float(cellDims.width) * backingScale, Float(cellDims.height) * backingScale) + uniforms.atlasSize = SIMD2(Float(GlyphAtlas.atlasSize), Float(GlyphAtlas.atlasSize)) + uniforms.cols = UInt32(cols) + uniforms.rows = UInt32(rows) + uniforms.time = Float(CACurrentMediaTime()) + uniforms.blinkOn = blinkOn ? 1 : 0 + uniforms.scrollY = 0 // Cell buffer already reads correct lines via bufferOffset (yDisp) + uniforms.backingScale = backingScale + + let ptr = uniformBuffer.contents().bindMemory(to: Uniforms.self, capacity: 1) + ptr.pointee = uniforms + } + + private func updateGlyphEntryBuffer() { + let size = glyphEntries.count * MemoryLayout.stride + guard size > 0 else { return } + + glyphEntryBuffer = device.makeBuffer( + bytes: &glyphEntries, + length: size, + options: .storageModeShared + ) + glyphBufferDirty = false + } + + // MARK: - Rendering + + private func renderFrame() { + guard let layer = metalLayer, + let drawable = layer.nextDrawable(), + let cellBuf = cellBuffer, + let glyphBuf = glyphEntryBuffer else { + return + } + + let cellCount = cols * rows + guard cellCount > 0 else { return } + + let passDescriptor = MTLRenderPassDescriptor() + passDescriptor.colorAttachments[0].texture = drawable.texture + passDescriptor.colorAttachments[0].loadAction = .clear + passDescriptor.colorAttachments[0].storeAction = .store + passDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0, 0, 0, 1) + + guard let commandBuffer = commandQueue.makeCommandBuffer(), + let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: passDescriptor) else { + return + } + + // Background pass + encoder.setRenderPipelineState(bgPipelineState) + encoder.setVertexBuffer(cellBuf, offset: 0, index: 0) + encoder.setVertexBuffer(uniformBuffer, offset: 0, index: 1) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6, instanceCount: cellCount) + + // Text pass + encoder.setRenderPipelineState(textPipelineState) + encoder.setVertexBuffer(cellBuf, offset: 0, index: 0) + encoder.setVertexBuffer(uniformBuffer, offset: 0, index: 1) + encoder.setVertexBuffer(glyphBuf, offset: 0, index: 2) + encoder.setFragmentBuffer(uniformBuffer, offset: 0, index: 1) + if let atlasTexture = glyphAtlas?.atlasTexture { + encoder.setFragmentTexture(atlasTexture, index: 0) + } + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6, instanceCount: cellCount) + + // Decoration pass (underline, strikethrough) + encoder.setRenderPipelineState(decoPipelineState) + encoder.setVertexBuffer(cellBuf, offset: 0, index: 0) + encoder.setVertexBuffer(uniformBuffer, offset: 0, index: 1) + encoder.setFragmentBuffer(uniformBuffer, offset: 0, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6, instanceCount: cellCount) + + // Image pass (Sixel/Kitty images as textured quads) + if !pendingImageQuads.isEmpty { + encoder.setRenderPipelineState(imagePipelineState) + encoder.setVertexBuffer(uniformBuffer, offset: 0, index: 1) + for entry in pendingImageQuads { + var quad = entry.quad + guard let quadBuffer = device.makeBuffer(bytes: &quad, length: MemoryLayout.stride, options: .storageModeShared) else { continue } + encoder.setVertexBuffer(quadBuffer, offset: 0, index: 0) + encoder.setFragmentTexture(entry.texture, index: 0) + encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6) + } + } + + encoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } + + // MARK: - Embedded Shader Source + + /// Metal shader source compiled at runtime for SPM compatibility. + static let shaderSource = """ + #include + using namespace metal; + + // Flag bits (must match Swift CellData.flags packing) + constant uint BLINK_BIT = (1u << 5); + + struct CellData { + uint16_t glyphIndex; + uint8_t fgR, fgG, fgB, fgA; + uint8_t bgR, bgG, bgB, bgA; + uint16_t flags; + uint16_t padding; + }; + + struct Uniforms { + float2 viewportSize; + float2 cellSize; + float2 atlasSize; + uint32_t cols; + uint32_t rows; + float time; + uint32_t blinkOn; + float scrollY; + float backingScale; + float _pad; + }; + + struct GlyphEntry { + float4 uvRect; + float2 bearing; + float2 size; + }; + + struct VertexOut { + float4 position [[position]]; + float2 texCoord; + float4 fgColor; + float4 bgColor; + uint flags [[flat]]; + }; + + // ---- Background Pass ---- + + vertex VertexOut bgVertex( + uint vertexID [[vertex_id]], + uint instanceID [[instance_id]], + constant CellData* cells [[buffer(0)]], + constant Uniforms& uniforms [[buffer(1)]] + ) { + uint col = instanceID % uniforms.cols; + uint row = instanceID / uniforms.cols; + + float2 positions[6] = { + {0, 0}, {1, 0}, {0, 1}, + {1, 0}, {1, 1}, {0, 1} + }; + + float2 pos = positions[vertexID]; + float2 cellOrigin = floor(float2(col, row) * uniforms.cellSize); + cellOrigin.y -= uniforms.scrollY; + float2 pixelPos = cellOrigin + pos * uniforms.cellSize; + + float2 clipPos = (pixelPos / uniforms.viewportSize) * 2.0 - 1.0; + clipPos.y = -clipPos.y; + + CellData cell = cells[instanceID]; + float4 bg = float4(cell.bgR, cell.bgG, cell.bgB, cell.bgA) / 255.0; + + VertexOut out; + out.position = float4(clipPos, 0.0, 1.0); + out.bgColor = bg; + out.texCoord = float2(0); + out.fgColor = float4(0); + out.flags = 0; + return out; + } + + fragment float4 bgFragment(VertexOut in [[stage_in]]) { + return in.bgColor; + } + + // ---- Text Pass ---- + + vertex VertexOut textVertex( + uint vertexID [[vertex_id]], + uint instanceID [[instance_id]], + constant CellData* cells [[buffer(0)]], + constant Uniforms& uniforms [[buffer(1)]], + constant GlyphEntry* glyphs [[buffer(2)]] + ) { + uint col = instanceID % uniforms.cols; + uint row = instanceID / uniforms.cols; + + CellData cell = cells[instanceID]; + GlyphEntry glyph = glyphs[cell.glyphIndex]; + + float2 positions[6] = { + {0, 0}, {1, 0}, {0, 1}, + {1, 0}, {1, 1}, {0, 1} + }; + float2 texCoords[6] = { + {glyph.uvRect.x, glyph.uvRect.y}, + {glyph.uvRect.z, glyph.uvRect.y}, + {glyph.uvRect.x, glyph.uvRect.w}, + {glyph.uvRect.z, glyph.uvRect.y}, + {glyph.uvRect.z, glyph.uvRect.w}, + {glyph.uvRect.x, glyph.uvRect.w} + }; + + float2 pos = positions[vertexID]; + // Snap cell origin to integer pixel boundary + float2 cellOrigin = floor(float2(col, row) * uniforms.cellSize); + cellOrigin.y -= uniforms.scrollY; + // Atlas is rasterized at backing resolution; glyph sizes are in backing pixels + float2 glyphOrigin = cellOrigin; + float2 pixelPos = glyphOrigin + pos * glyph.size; + + float2 clipPos = (pixelPos / uniforms.viewportSize) * 2.0 - 1.0; + clipPos.y = -clipPos.y; + + float4 fg = float4(cell.fgR, cell.fgG, cell.fgB, cell.fgA) / 255.0; + + VertexOut out; + out.position = float4(clipPos, 0.0, 1.0); + out.texCoord = texCoords[vertexID]; + out.fgColor = fg; + out.bgColor = float4(0); + out.flags = cell.flags; + return out; + } + + fragment float4 textFragment( + VertexOut in [[stage_in]], + texture2d atlas [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] + ) { + // Blink: hide glyph when blink flag is set and blink phase is off + if ((in.flags & BLINK_BIT) != 0 && uniforms.blinkOn == 0) { + discard_fragment(); + } + + constexpr sampler s(filter::nearest); + float alpha = atlas.sample(s, in.texCoord).r; + + // Binary threshold: AA gives full glyph shape, step makes edges crisp + alpha = step(0.35, alpha); + if (alpha < 0.01) discard_fragment(); + + return float4(in.fgColor.rgb, in.fgColor.a * alpha); + } + + // ---- Decoration Pass (underline, strikethrough) ---- + + vertex VertexOut decoVertex( + uint vertexID [[vertex_id]], + uint instanceID [[instance_id]], + constant CellData* cells [[buffer(0)]], + constant Uniforms& uniforms [[buffer(1)]] + ) { + uint col = instanceID % uniforms.cols; + uint row = instanceID / uniforms.cols; + + float2 positions[6] = { + {0, 0}, {1, 0}, {0, 1}, + {1, 0}, {1, 1}, {0, 1} + }; + + float2 pos = positions[vertexID]; + float2 cellOrigin = floor(float2(col, row) * uniforms.cellSize); + cellOrigin.y -= uniforms.scrollY; + float2 pixelPos = cellOrigin + pos * uniforms.cellSize; + + float2 clipPos = (pixelPos / uniforms.viewportSize) * 2.0 - 1.0; + clipPos.y = -clipPos.y; + + CellData cell = cells[instanceID]; + float4 fg = float4(cell.fgR, cell.fgG, cell.fgB, cell.fgA) / 255.0; + + VertexOut out; + out.position = float4(clipPos, 0.0, 1.0); + out.texCoord = pos; // cell-local position (0..1) + out.fgColor = fg; + out.bgColor = float4(0); + out.flags = cell.flags; + return out; + } + + fragment float4 decoFragment( + VertexOut in [[stage_in]], + constant Uniforms& uniforms [[buffer(0)]] + ) { + uint flags = in.flags; + bool hasUnderline = (flags & (1u << 2)) != 0; + bool hasStrikethrough = (flags & (1u << 3)) != 0; + bool hasCursorDeco = (flags & ((1u << 6) | (1u << 7))) != 0; + + if (!hasUnderline && !hasStrikethrough && !hasCursorDeco) discard_fragment(); + + float y = in.texCoord.y; + float pixelH = 1.0 / uniforms.cellSize.y; + + bool draw = false; + + // Underline: 1px line 3 pixels from bottom of cell + if (hasUnderline) { + float underlineY = 1.0 - 3.0 * pixelH; + if (y >= underlineY && y < underlineY + pixelH) draw = true; + } + + // Strikethrough: 1px line at vertical center + if (hasStrikethrough) { + float strikeY = 0.5 - 0.5 * pixelH; + if (y >= strikeY && y < strikeY + pixelH) draw = true; + } + + // Cursor bar: 2px vertical line on left side of cell + bool hasCursorBar = (flags & (1u << 6)) != 0; + if (hasCursorBar) { + float pixelW = 1.0 / uniforms.cellSize.x; + if (in.texCoord.x < 2.0 * pixelW) draw = true; + } + + // Cursor underline: 2px horizontal line at bottom of cell + bool hasCursorUline = (flags & (1u << 7)) != 0; + if (hasCursorUline) { + if (y >= 1.0 - 2.0 * pixelH) draw = true; + } + + if (!draw) discard_fragment(); + + return in.fgColor; + } + + // ---- Image Pass (Sixel/Kitty textured quads) ---- + + struct ImageQuad { + float2 position; // top-left in pixels + float2 size; // width, height in pixels + }; + + struct ImageVertexOut { + float4 position [[position]]; + float2 texCoord; + }; + + vertex ImageVertexOut imageVertex( + uint vertexID [[vertex_id]], + constant ImageQuad& quad [[buffer(0)]], + constant Uniforms& uniforms [[buffer(1)]] + ) { + float2 positions[6] = { + {0, 0}, {1, 0}, {0, 1}, + {1, 0}, {1, 1}, {0, 1} + }; + float2 uvs[6] = { + {0, 0}, {1, 0}, {0, 1}, + {1, 0}, {1, 1}, {0, 1} + }; + + float2 pos = positions[vertexID]; + float2 pixelPos = quad.position + pos * quad.size; + + float2 clipPos = (pixelPos / uniforms.viewportSize) * 2.0 - 1.0; + clipPos.y = -clipPos.y; + + ImageVertexOut out; + out.position = float4(clipPos, 0.0, 1.0); + out.texCoord = uvs[vertexID]; + return out; + } + + fragment float4 imageFragment( + ImageVertexOut in [[stage_in]], + texture2d imageTexture [[texture(0)]] + ) { + constexpr sampler s(filter::linear); + return imageTexture.sample(s, in.texCoord); + } + """ +} +#endif diff --git a/Sources/SwiftTerm/Rendering/ShaderTypes.h b/Sources/SwiftTerm/Rendering/ShaderTypes.h new file mode 100644 index 00000000..a6a7942f --- /dev/null +++ b/Sources/SwiftTerm/Rendering/ShaderTypes.h @@ -0,0 +1,38 @@ +// +// ShaderTypes.h +// +// Shared type definitions used by both Metal shaders and Swift code. +// These structs define the GPU-side layout for terminal cell rendering. +// + +#ifndef ShaderTypes_h +#define ShaderTypes_h + +#include + +// Matches Swift CellData struct +struct CellData { + uint16_t glyphIndex; + uint8_t fgR, fgG, fgB, fgA; + uint8_t bgR, bgG, bgB, bgA; + uint16_t flags; // bit 0: bold, 1: italic, 2: underline, 3: strikethrough, 4: inverse, 5: blink, 6: dim + uint16_t padding; +}; + +struct Uniforms { + simd_float2 viewportSize; // in pixels + simd_float2 cellSize; // cell width/height in pixels + simd_float2 atlasSize; // atlas texture dimensions + uint32_t cols; + uint32_t rows; + float time; // for blink animation + uint32_t blinkOn; // blink state (0 or 1) +}; + +struct GlyphEntry { + simd_float4 uvRect; // u0, v0, u1, v1 + simd_float2 bearing; // bearingX, bearingY + simd_float2 size; // glyph width, height in pixels +}; + +#endif diff --git a/Sources/SwiftTerm/Rendering/TerminalRenderer.swift b/Sources/SwiftTerm/Rendering/TerminalRenderer.swift new file mode 100644 index 00000000..0d5b04a0 --- /dev/null +++ b/Sources/SwiftTerm/Rendering/TerminalRenderer.swift @@ -0,0 +1,59 @@ +// +// TerminalRenderer.swift +// +// Protocol abstracting the terminal rendering layer so that different +// backends (CoreGraphics, Metal, etc.) can be swapped in. +// +#if os(macOS) || os(iOS) || os(visionOS) +import Foundation +import CoreGraphics + +/// Dimensions of a single terminal cell used during rendering. +public struct CellDimensions { + public let width: CGFloat + public let height: CGFloat + public let descent: CGFloat + public let leading: CGFloat + + public init(width: CGFloat, height: CGFloat, descent: CGFloat, leading: CGFloat) { + self.width = width + self.height = height + self.descent = descent + self.leading = leading + } +} + +/// Protocol abstracting terminal rendering (CoreGraphics, Metal, etc.). +public protocol TerminalRenderer: AnyObject { + /// Called once to set up the renderer with the terminal view. + func setup(view: TerminalView) + + /// Perform the actual rendering into the given CoreGraphics context. + func draw( + in context: CGContext, + dirtyRect: CGRect, + cellDimensions: CellDimensions, + bufferOffset: Int + ) + + /// Handle terminal resize. + func resize(cols: Int, rows: Int, cellDimensions: CellDimensions) + + /// Handle color scheme changes. + func colorsChanged() + + /// Handle font changes. + func fontChanged() + + /// Invalidate all cached rendering data. + func invalidateAll() +} + +/// Default implementations so conformers only need to override what they care about. +public extension TerminalRenderer { + func resize(cols: Int, rows: Int, cellDimensions: CellDimensions) {} + func colorsChanged() {} + func fontChanged() {} + func invalidateAll() {} +} +#endif diff --git a/Sources/SwiftTerm/Rendering/TerminalShaders.metal b/Sources/SwiftTerm/Rendering/TerminalShaders.metal new file mode 100644 index 00000000..68920c36 --- /dev/null +++ b/Sources/SwiftTerm/Rendering/TerminalShaders.metal @@ -0,0 +1,216 @@ +// +// TerminalShaders.metal +// +// Metal shaders for GPU-accelerated terminal rendering. +// Uses instanced drawing: one instance per terminal cell. +// +// Note: SPM does not compile .metal files. The shader source is embedded +// as a Swift string in MetalTerminalRenderer and compiled at runtime via +// device.makeLibrary(source:options:). This file serves as the canonical +// reference and for Xcode project builds. +// + +#include +using namespace metal; + +// Structs duplicated here because .metal files cannot include .h in SPM builds. + +struct CellData { + uint16_t glyphIndex; + uint8_t fgR, fgG, fgB, fgA; + uint8_t bgR, bgG, bgB, bgA; + uint16_t flags; + uint16_t padding; +}; + +struct Uniforms { + float2 viewportSize; + float2 cellSize; + float2 atlasSize; + uint32_t cols; + uint32_t rows; + float time; + uint32_t blinkOn; +}; + +struct GlyphEntry { + float4 uvRect; // u0, v0, u1, v1 + float2 bearing; // bearingX, bearingY + float2 size; // glyph width, height in pixels +}; + +struct VertexOut { + float4 position [[position]]; + float2 texCoord; + float4 fgColor; + float4 bgColor; + uint flags [[flat]]; +}; + +// ---- Background Pass ---- + +vertex VertexOut bgVertex( + uint vertexID [[vertex_id]], + uint instanceID [[instance_id]], + constant CellData* cells [[buffer(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + uint col = instanceID % uniforms.cols; + uint row = instanceID / uniforms.cols; + + // 6 vertices per quad (2 triangles) + float2 positions[6] = { + {0, 0}, {1, 0}, {0, 1}, + {1, 0}, {1, 1}, {0, 1} + }; + + float2 pos = positions[vertexID]; + float2 cellOrigin = float2(col, row) * uniforms.cellSize; + float2 pixelPos = cellOrigin + pos * uniforms.cellSize; + + // Convert to clip space (-1..1) + float2 clipPos = (pixelPos / uniforms.viewportSize) * 2.0 - 1.0; + clipPos.y = -clipPos.y; // Flip Y + + CellData cell = cells[instanceID]; + float4 bg = float4(cell.bgR, cell.bgG, cell.bgB, cell.bgA) / 255.0; + + VertexOut out; + out.position = float4(clipPos, 0.0, 1.0); + out.bgColor = bg; + out.texCoord = float2(0); + out.fgColor = float4(0); + out.flags = 0; + return out; +} + +fragment float4 bgFragment(VertexOut in [[stage_in]]) { + return in.bgColor; +} + +// ---- Text Pass ---- + +vertex VertexOut textVertex( + uint vertexID [[vertex_id]], + uint instanceID [[instance_id]], + constant CellData* cells [[buffer(0)]], + constant Uniforms& uniforms [[buffer(1)]], + constant GlyphEntry* glyphs [[buffer(2)]] +) { + uint col = instanceID % uniforms.cols; + uint row = instanceID / uniforms.cols; + + CellData cell = cells[instanceID]; + GlyphEntry glyph = glyphs[cell.glyphIndex]; + + float2 positions[6] = { + {0, 0}, {1, 0}, {0, 1}, + {1, 0}, {1, 1}, {0, 1} + }; + float2 texCoords[6] = { + {glyph.uvRect.x, glyph.uvRect.y}, + {glyph.uvRect.z, glyph.uvRect.y}, + {glyph.uvRect.x, glyph.uvRect.w}, + {glyph.uvRect.z, glyph.uvRect.y}, + {glyph.uvRect.z, glyph.uvRect.w}, + {glyph.uvRect.x, glyph.uvRect.w} + }; + + float2 pos = positions[vertexID]; + float2 cellOrigin = float2(col, row) * uniforms.cellSize; + float2 glyphOrigin = cellOrigin + float2(glyph.bearing.x, glyph.bearing.y); + float2 pixelPos = glyphOrigin + pos * glyph.size; + + float2 clipPos = (pixelPos / uniforms.viewportSize) * 2.0 - 1.0; + clipPos.y = -clipPos.y; + + float4 fg = float4(cell.fgR, cell.fgG, cell.fgB, cell.fgA) / 255.0; + + VertexOut out; + out.position = float4(clipPos, 0.0, 1.0); + out.texCoord = texCoords[vertexID]; + out.fgColor = fg; + out.bgColor = float4(0); + out.flags = cell.flags; + return out; +} + +fragment float4 textFragment( + VertexOut in [[stage_in]], + texture2d atlas [[texture(0)]] +) { + constexpr sampler s(filter::linear); + float alpha = atlas.sample(s, in.texCoord).r; + + // Skip empty glyphs + if (alpha < 0.01) discard_fragment(); + + return float4(in.fgColor.rgb, in.fgColor.a * alpha); +} + +// ---- Decoration Pass (underline, strikethrough) ---- + +vertex VertexOut decoVertex( + uint vertexID [[vertex_id]], + uint instanceID [[instance_id]], + constant CellData* cells [[buffer(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + uint col = instanceID % uniforms.cols; + uint row = instanceID / uniforms.cols; + + float2 positions[6] = { + {0, 0}, {1, 0}, {0, 1}, + {1, 0}, {1, 1}, {0, 1} + }; + + float2 pos = positions[vertexID]; + float2 cellOrigin = float2(col, row) * uniforms.cellSize; + float2 pixelPos = cellOrigin + pos * uniforms.cellSize; + + float2 clipPos = (pixelPos / uniforms.viewportSize) * 2.0 - 1.0; + clipPos.y = -clipPos.y; + + CellData cell = cells[instanceID]; + float4 fg = float4(cell.fgR, cell.fgG, cell.fgB, cell.fgA) / 255.0; + + VertexOut out; + out.position = float4(clipPos, 0.0, 1.0); + out.texCoord = pos; // cell-local position (0..1) + out.fgColor = fg; + out.bgColor = float4(0); + out.flags = cell.flags; + return out; +} + +fragment float4 decoFragment( + VertexOut in [[stage_in]], + constant Uniforms& uniforms [[buffer(0)]] +) { + uint flags = in.flags; + bool hasUnderline = (flags & (1u << 2)) != 0; + bool hasStrikethrough = (flags & (1u << 3)) != 0; + + if (!hasUnderline && !hasStrikethrough) discard_fragment(); + + float y = in.texCoord.y; + float pixelH = 1.0 / uniforms.cellSize.y; + + bool draw = false; + + // Underline: 1px line 3 pixels from bottom of cell + if (hasUnderline) { + float underlineY = 1.0 - 3.0 * pixelH; + if (y >= underlineY && y < underlineY + pixelH) draw = true; + } + + // Strikethrough: 1px line at vertical center + if (hasStrikethrough) { + float strikeY = 0.5 - 0.5 * pixelH; + if (y >= strikeY && y < strikeY + pixelH) draw = true; + } + + if (!draw) discard_fragment(); + + return in.fgColor; +}