Skip to content

Commit ccea7f7

Browse files
roninjin10claude
andcommitted
✨ feat: implement Metal renderer with Ghostty-inspired aesthetics
- Added complete font atlas generation using Core Text - Fixed transform matrix calculations for proper 2D rendering - Enhanced terminal colors with Ghostty-inspired palette - Implemented GPU/CPU renderer toggle in terminal header - Fixed shader pipeline to properly render text with textures - Improved cursor rendering with vibrant blue color - Added proper orthographic projection for Metal rendering The terminal now supports both NSView and Metal backends with seamless switching, providing beautiful Ghostty-like visuals. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent efbf4fa commit ccea7f7

File tree

6 files changed

+266
-85
lines changed

6 files changed

+266
-85
lines changed

Sources/plue/ANSIParser.swift

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,24 @@ enum ANSIColor: Int {
2424
case brightWhite = 97
2525

2626
func toNSColor() -> NSColor {
27+
// Ghostty-inspired color palette
2728
switch self {
28-
case .black, .brightBlack: return .black
29-
case .red: return NSColor(red: 0.8, green: 0, blue: 0, alpha: 1)
30-
case .brightRed: return NSColor(red: 1, green: 0, blue: 0, alpha: 1)
31-
case .green: return NSColor(red: 0, green: 0.8, blue: 0, alpha: 1)
32-
case .brightGreen: return NSColor(red: 0, green: 1, blue: 0, alpha: 1)
33-
case .yellow: return NSColor(red: 0.8, green: 0.8, blue: 0, alpha: 1)
34-
case .brightYellow: return NSColor(red: 1, green: 1, blue: 0, alpha: 1)
35-
case .blue: return NSColor(red: 0, green: 0, blue: 0.8, alpha: 1)
36-
case .brightBlue: return NSColor(red: 0, green: 0, blue: 1, alpha: 1)
37-
case .magenta: return NSColor(red: 0.8, green: 0, blue: 0.8, alpha: 1)
38-
case .brightMagenta: return NSColor(red: 1, green: 0, blue: 1, alpha: 1)
39-
case .cyan: return NSColor(red: 0, green: 0.8, blue: 0.8, alpha: 1)
40-
case .brightCyan: return NSColor(red: 0, green: 1, blue: 1, alpha: 1)
41-
case .white: return NSColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1)
42-
case .brightWhite, .defaultColor: return .white
29+
case .black: return NSColor(red: 0.173, green: 0.173, blue: 0.216, alpha: 1)
30+
case .brightBlack: return NSColor(red: 0.373, green: 0.373, blue: 0.416, alpha: 1)
31+
case .red: return NSColor(red: 0.937, green: 0.325, blue: 0.314, alpha: 1)
32+
case .brightRed: return NSColor(red: 0.992, green: 0.592, blue: 0.588, alpha: 1)
33+
case .green: return NSColor(red: 0.584, green: 0.831, blue: 0.373, alpha: 1)
34+
case .brightGreen: return NSColor(red: 0.702, green: 0.933, blue: 0.612, alpha: 1)
35+
case .yellow: return NSColor(red: 0.988, green: 0.914, blue: 0.310, alpha: 1)
36+
case .brightYellow: return NSColor(red: 0.988, green: 0.945, blue: 0.553, alpha: 1)
37+
case .blue: return NSColor(red: 0.149, green: 0.545, blue: 0.824, alpha: 1)
38+
case .brightBlue: return NSColor(red: 0.514, green: 0.753, blue: 0.988, alpha: 1)
39+
case .magenta: return NSColor(red: 0.827, green: 0.529, blue: 0.937, alpha: 1)
40+
case .brightMagenta: return NSColor(red: 0.933, green: 0.682, blue: 0.988, alpha: 1)
41+
case .cyan: return NSColor(red: 0.329, green: 0.843, blue: 0.859, alpha: 1)
42+
case .brightCyan: return NSColor(red: 0.596, green: 0.929, blue: 0.941, alpha: 1)
43+
case .white: return NSColor(red: 0.925, green: 0.937, blue: 0.953, alpha: 1)
44+
case .brightWhite, .defaultColor: return NSColor(red: 0.976, green: 0.976, blue: 0.976, alpha: 1)
4345
}
4446
}
4547
}

Sources/plue/MetalTerminalRenderer.swift

Lines changed: 165 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import MetalKit
22
import CoreText
3+
import CoreGraphics
34

45
// MARK: - Metal Terminal Renderer
56
class MetalTerminalRenderer: NSObject {
@@ -194,9 +195,9 @@ class MetalTerminalRenderer: NSObject {
194195

195196
viewportSize = SIMD2<Float>(Float(view.drawableSize.width), Float(view.drawableSize.height))
196197

197-
// Clear background
198+
// Clear background with Ghostty-inspired color
198199
renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColor(
199-
red: 0.1, green: 0.1, blue: 0.1, alpha: 1.0
200+
red: 0.086, green: 0.086, blue: 0.11, alpha: 1.0
200201
)
201202

202203
// Set viewport
@@ -229,14 +230,14 @@ class MetalTerminalRenderer: NSObject {
229230
// First pass: render background colors
230231
renderEncoder.setRenderPipelineState(backgroundPipelineState)
231232
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
232-
renderEncoder.setVertexBytes(&viewportSize, length: MemoryLayout<SIMD2<Float>>.size, index: 0)
233233

234234
for row in 0..<buffer.rows {
235235
for col in 0..<buffer.cols {
236236
let cell = buffer.getCell(row: row, col: col)
237237

238238
// Skip default background
239-
if cell.backgroundColor == .black { continue }
239+
let defaultBg = NSColor(red: 0.086, green: 0.086, blue: 0.11, alpha: 1.0)
240+
if cell.backgroundColor == defaultBg { continue }
240241

241242
let rect = cellRect(row: row, col: col)
242243
var transform = makeTransform(rect: rect)
@@ -262,12 +263,30 @@ class MetalTerminalRenderer: NSObject {
262263

263264
guard let glyph = fontAtlas.glyph(for: cell.character) else { continue }
264265

266+
// Create vertex buffer with proper texture coordinates for this glyph
267+
let texRect = glyph.texCoords
268+
let vertices: [Float] = [
269+
// Position (x, y), TexCoord (u, v)
270+
0, 0, Float(texRect.minX), Float(texRect.minY), // Top-left
271+
1, 0, Float(texRect.maxX), Float(texRect.minY), // Top-right
272+
0, 1, Float(texRect.minX), Float(texRect.maxY), // Bottom-left
273+
1, 1, Float(texRect.maxX), Float(texRect.maxY), // Bottom-right
274+
]
275+
276+
// Create temporary buffer for this glyph
277+
guard let glyphBuffer = device.makeBuffer(bytes: vertices,
278+
length: vertices.count * MemoryLayout<Float>.size,
279+
options: .storageModeShared) else {
280+
continue
281+
}
282+
265283
let rect = cellRect(row: row, col: col)
266-
var transform = makeTransform(rect: rect, texCoords: glyph.texCoords)
284+
var transform = makeTransform(rect: rect)
267285

268286
var textColor = colorToFloat4(cell.foregroundColor)
269287
var bgColor = colorToFloat4(cell.backgroundColor)
270288

289+
renderEncoder.setVertexBuffer(glyphBuffer, offset: 0, index: 0)
271290
renderEncoder.setVertexBytes(&transform, length: MemoryLayout<simd_float4x4>.size, index: 1)
272291
renderEncoder.setFragmentBytes(&textColor, length: MemoryLayout<SIMD4<Float>>.size, index: 0)
273292
renderEncoder.setFragmentBytes(&bgColor, length: MemoryLayout<SIMD4<Float>>.size, index: 1)
@@ -279,13 +298,12 @@ class MetalTerminalRenderer: NSObject {
279298
private func renderCursor(buffer: TerminalBuffer, with renderEncoder: MTLRenderCommandEncoder) {
280299
renderEncoder.setRenderPipelineState(cursorPipelineState)
281300
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
282-
renderEncoder.setVertexBytes(&viewportSize, length: MemoryLayout<SIMD2<Float>>.size, index: 0)
283301

284302
let (row, col) = buffer.cursorPosition
285303
let rect = cellRect(row: row, col: col)
286304
var transform = makeTransform(rect: rect)
287305

288-
var cursorColor = SIMD4<Float>(1, 1, 1, 0.8)
306+
var cursorColor = SIMD4<Float>(0.5, 0.8, 1.0, 0.8) // Nice blue cursor
289307
var time = Float(CACurrentMediaTime())
290308

291309
renderEncoder.setVertexBytes(&transform, length: MemoryLayout<simd_float4x4>.size, index: 1)
@@ -297,7 +315,6 @@ class MetalTerminalRenderer: NSObject {
297315
private func renderSelection(_ selection: TerminalSelection, buffer: TerminalBuffer, with renderEncoder: MTLRenderCommandEncoder) {
298316
renderEncoder.setRenderPipelineState(selectionPipelineState)
299317
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
300-
renderEncoder.setVertexBytes(&viewportSize, length: MemoryLayout<SIMD2<Float>>.size, index: 0)
301318

302319
var selectionColor = SIMD4<Float>(0.5, 0.5, 0.8, 0.3)
303320

@@ -332,9 +349,31 @@ class MetalTerminalRenderer: NSObject {
332349
}
333350

334351
private func makeTransform(rect: CGRect, texCoords: CGRect? = nil) -> simd_float4x4 {
335-
// For now, just return identity - in real implementation this would
336-
// transform the quad to the correct position
337-
return simd_float4x4(1)
352+
// Create an orthographic projection matrix for 2D rendering
353+
let scaleX = 2.0 / Float(viewportSize.x)
354+
let scaleY = -2.0 / Float(viewportSize.y) // Flip Y coordinate
355+
356+
// Translate to normalized device coordinates
357+
let translateX = -1.0 + Float(rect.origin.x) * scaleX
358+
let translateY = 1.0 + Float(rect.origin.y) * scaleY
359+
360+
// Scale to match the cell size
361+
let sizeX = Float(rect.width) * scaleX
362+
let sizeY = Float(rect.height) * scaleY
363+
364+
// Build the transformation matrix
365+
var transform = simd_float4x4(1) // Start with identity
366+
367+
// Column 0: X scaling
368+
transform.columns.0 = SIMD4<Float>(sizeX, 0, 0, 0)
369+
// Column 1: Y scaling
370+
transform.columns.1 = SIMD4<Float>(0, sizeY, 0, 0)
371+
// Column 2: Z (unchanged)
372+
transform.columns.2 = SIMD4<Float>(0, 0, 1, 0)
373+
// Column 3: Translation
374+
transform.columns.3 = SIMD4<Float>(translateX, translateY, 0, 1)
375+
376+
return transform
338377
}
339378

340379
private func colorToFloat4(_ color: NSColor) -> SIMD4<Float> {
@@ -354,6 +393,7 @@ class FontAtlas {
354393
private let device: MTLDevice
355394
private let atlasSize = 1024
356395
private var glyphs: [Character: GlyphInfo] = [:]
396+
private var atlasData: UnsafeMutableRawPointer?
357397

358398
struct GlyphInfo {
359399
let texCoords: CGRect
@@ -369,8 +409,88 @@ class FontAtlas {
369409

370410
private func generateAtlas() {
371411
// Generate atlas for ASCII printable characters
372-
// In a real implementation, this would create a texture atlas
373-
// with all glyphs rendered using Core Text
412+
let context = CGContext(
413+
data: nil,
414+
width: atlasSize,
415+
height: atlasSize,
416+
bitsPerComponent: 8,
417+
bytesPerRow: atlasSize,
418+
space: CGColorSpaceCreateDeviceGray(),
419+
bitmapInfo: CGImageAlphaInfo.none.rawValue
420+
)
421+
422+
guard let ctx = context else { return }
423+
424+
// Clear the context
425+
ctx.setFillColor(CGColor(gray: 0, alpha: 1))
426+
ctx.fill(CGRect(x: 0, y: 0, width: atlasSize, height: atlasSize))
427+
428+
// Set up text rendering
429+
ctx.setFillColor(CGColor(gray: 1, alpha: 1))
430+
ctx.setFont(CGFont(font.fontName as CFString)!)
431+
ctx.setFontSize(font.pointSize)
432+
433+
// Render glyphs in a grid
434+
let padding: CGFloat = 2
435+
var x: CGFloat = padding
436+
var y: CGFloat = padding
437+
let lineHeight = font.capHeight + font.descender + font.leading + padding * 2
438+
439+
// ASCII printable characters (32-126)
440+
for asciiValue in 32...126 {
441+
let char = Character(UnicodeScalar(asciiValue)!)
442+
let str = String(char)
443+
444+
// Measure the glyph
445+
let attributes: [NSAttributedString.Key: Any] = [.font: font]
446+
let size = NSAttributedString(string: str, attributes: attributes).size()
447+
448+
// Check if we need to move to the next line
449+
if x + size.width + padding > CGFloat(atlasSize) {
450+
x = padding
451+
y += lineHeight
452+
}
453+
454+
// Skip if we're out of space
455+
if y + lineHeight > CGFloat(atlasSize) {
456+
break
457+
}
458+
459+
// Draw the glyph
460+
ctx.saveGState()
461+
ctx.translateBy(x: 0, y: CGFloat(atlasSize))
462+
ctx.scaleBy(x: 1, y: -1)
463+
464+
let rect = CGRect(x: x, y: CGFloat(atlasSize) - y - lineHeight, width: size.width, height: lineHeight)
465+
ctx.setTextDrawingMode(.fill)
466+
467+
let attrString = NSAttributedString(string: str, attributes: attributes)
468+
let line = CTLineCreateWithAttributedString(attrString)
469+
ctx.textPosition = CGPoint(x: x, y: CGFloat(atlasSize) - y - font.descender)
470+
CTLineDraw(line, ctx)
471+
472+
ctx.restoreGState()
473+
474+
// Store glyph info
475+
let texCoords = CGRect(
476+
x: x / CGFloat(atlasSize),
477+
y: y / CGFloat(atlasSize),
478+
width: size.width / CGFloat(atlasSize),
479+
height: lineHeight / CGFloat(atlasSize)
480+
)
481+
482+
glyphs[char] = GlyphInfo(
483+
texCoords: texCoords,
484+
size: size,
485+
offset: CGPoint(x: 0, y: 0)
486+
)
487+
488+
// Move to next position
489+
x += size.width + padding
490+
}
491+
492+
// Store the bitmap data for texture creation
493+
self.atlasData = context?.data
374494
}
375495

376496
func createTexture() -> MTLTexture? {
@@ -381,7 +501,19 @@ class FontAtlas {
381501
mipmapped: false
382502
)
383503

384-
return device.makeTexture(descriptor: descriptor)
504+
guard let texture = device.makeTexture(descriptor: descriptor),
505+
let data = atlasData else {
506+
return nil
507+
}
508+
509+
texture.replace(
510+
region: MTLRegionMake2D(0, 0, atlasSize, atlasSize),
511+
mipmapLevel: 0,
512+
withBytes: data,
513+
bytesPerRow: atlasSize
514+
)
515+
516+
return texture
385517
}
386518

387519
func glyph(for character: Character) -> GlyphInfo? {
@@ -399,24 +531,25 @@ struct TerminalSelection {
399531

400532
// MARK: - Terminal Color Palette
401533
struct TerminalColorPalette {
402-
let black = NSColor(red: 0.0, green: 0.0, blue: 0.0, alpha: 1.0)
403-
let red = NSColor(red: 0.8, green: 0.0, blue: 0.0, alpha: 1.0)
404-
let green = NSColor(red: 0.0, green: 0.8, blue: 0.0, alpha: 1.0)
405-
let yellow = NSColor(red: 0.8, green: 0.8, blue: 0.0, alpha: 1.0)
406-
let blue = NSColor(red: 0.0, green: 0.0, blue: 0.8, alpha: 1.0)
407-
let magenta = NSColor(red: 0.8, green: 0.0, blue: 0.8, alpha: 1.0)
408-
let cyan = NSColor(red: 0.0, green: 0.8, blue: 0.8, alpha: 1.0)
409-
let white = NSColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1.0)
534+
// Ghostty-inspired colors
535+
let black = NSColor(red: 0.173, green: 0.173, blue: 0.216, alpha: 1)
536+
let red = NSColor(red: 0.937, green: 0.325, blue: 0.314, alpha: 1)
537+
let green = NSColor(red: 0.584, green: 0.831, blue: 0.373, alpha: 1)
538+
let yellow = NSColor(red: 0.988, green: 0.914, blue: 0.310, alpha: 1)
539+
let blue = NSColor(red: 0.149, green: 0.545, blue: 0.824, alpha: 1)
540+
let magenta = NSColor(red: 0.827, green: 0.529, blue: 0.937, alpha: 1)
541+
let cyan = NSColor(red: 0.329, green: 0.843, blue: 0.859, alpha: 1)
542+
let white = NSColor(red: 0.925, green: 0.937, blue: 0.953, alpha: 1)
410543

411-
let brightBlack = NSColor(red: 0.4, green: 0.4, blue: 0.4, alpha: 1.0)
412-
let brightRed = NSColor(red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)
413-
let brightGreen = NSColor(red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0)
414-
let brightYellow = NSColor(red: 1.0, green: 1.0, blue: 0.0, alpha: 1.0)
415-
let brightBlue = NSColor(red: 0.0, green: 0.0, blue: 1.0, alpha: 1.0)
416-
let brightMagenta = NSColor(red: 1.0, green: 0.0, blue: 1.0, alpha: 1.0)
417-
let brightCyan = NSColor(red: 0.0, green: 1.0, blue: 1.0, alpha: 1.0)
418-
let brightWhite = NSColor(red: 1.0, green: 1.0, blue: 1.0, alpha: 1.0)
544+
let brightBlack = NSColor(red: 0.373, green: 0.373, blue: 0.416, alpha: 1)
545+
let brightRed = NSColor(red: 0.992, green: 0.592, blue: 0.588, alpha: 1)
546+
let brightGreen = NSColor(red: 0.702, green: 0.933, blue: 0.612, alpha: 1)
547+
let brightYellow = NSColor(red: 0.988, green: 0.945, blue: 0.553, alpha: 1)
548+
let brightBlue = NSColor(red: 0.514, green: 0.753, blue: 0.988, alpha: 1)
549+
let brightMagenta = NSColor(red: 0.933, green: 0.682, blue: 0.988, alpha: 1)
550+
let brightCyan = NSColor(red: 0.596, green: 0.929, blue: 0.941, alpha: 1)
551+
let brightWhite = NSColor(red: 0.976, green: 0.976, blue: 0.976, alpha: 1)
419552

420-
let background = NSColor(red: 0.1, green: 0.1, blue: 0.1, alpha: 1.0)
421-
let foreground = NSColor(red: 0.9, green: 0.9, blue: 0.9, alpha: 1.0)
553+
let background = NSColor(red: 0.086, green: 0.086, blue: 0.11, alpha: 1.0)
554+
let foreground = NSColor(red: 0.976, green: 0.976, blue: 0.976, alpha: 1.0)
422555
}

Sources/plue/TerminalEmulator.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import AppKit
44
// MARK: - Terminal Cell
55
struct TerminalEmulatorCell {
66
var character: Character = " "
7-
var foregroundColor: NSColor = .white
8-
var backgroundColor: NSColor = .black
7+
var foregroundColor: NSColor = NSColor(red: 0.976, green: 0.976, blue: 0.976, alpha: 1.0)
8+
var backgroundColor: NSColor = NSColor(red: 0.086, green: 0.086, blue: 0.11, alpha: 1.0)
99
var isBold: Bool = false
1010
var isUnderlined: Bool = false
1111
}
@@ -23,8 +23,8 @@ class TerminalBuffer {
2323
private var scrollBottom: Int
2424

2525
// Current attributes
26-
private var currentForeground: NSColor = .white
27-
private var currentBackground: NSColor = .black
26+
private var currentForeground: NSColor = NSColor(red: 0.976, green: 0.976, blue: 0.976, alpha: 1.0)
27+
private var currentBackground: NSColor = NSColor(red: 0.086, green: 0.086, blue: 0.11, alpha: 1.0)
2828
private var currentBold: Bool = false
2929
private var currentUnderline: Bool = false
3030

@@ -162,8 +162,8 @@ class TerminalBuffer {
162162
}
163163

164164
func resetAttributes() {
165-
currentForeground = .white
166-
currentBackground = .black
165+
currentForeground = NSColor(red: 0.976, green: 0.976, blue: 0.976, alpha: 1.0)
166+
currentBackground = NSColor(red: 0.086, green: 0.086, blue: 0.11, alpha: 1.0)
167167
currentBold = false
168168
currentUnderline = false
169169
}

0 commit comments

Comments
 (0)