Files
TUIkit/Sources/TUIkitImage/RGBAImage.swift
T
phranck ce850e1b29 Refactor: Extract TUIkitStyling and TUIkitImage modules
- Extract 12 pure type definitions (Color, Palette, Appearance, BorderStyle, etc.) into TUIkitStyling module with no dependencies
- Extract 3 image processing files (RGBAImage, ImageLoader, ASCIIConverter) into TUIkitImage module (deps: CSTBImage, TUIkitStyling)
- Split 6 mixed files into type definitions (TUIkitStyling) and environment glue (TUIkit/Styling/)
- Decouple ThemeManager from AppState via renderTrigger closure
- Decouple ASCIIConverter from ANSIRenderer via local ANSIEscape constants
- Add @_exported imports in Exports.swift for backward compatibility
- All 1064 tests pass, no breaking API changes
2026-02-14 03:14:14 +01:00

210 lines
6.7 KiB
Swift

// 🖥️ TUIKit — Terminal UI Kit for Swift
// RGBAImage.swift
//
// Created by LAYERED.work
// License: MIT
// MARK: - RGBA Pixel
/// A single pixel with red, green, blue, and alpha channels.
///
/// Used as the intermediate representation for image data before
/// ASCII art conversion. Each channel is stored as a `UInt8` (0-255).
public struct RGBA: Sendable, Equatable {
public var r: UInt8
public var g: UInt8
public var b: UInt8
public var a: UInt8
/// Creates an opaque pixel with the given RGB values.
public init(r: UInt8, g: UInt8, b: UInt8, a: UInt8 = 255) {
self.r = r
self.g = g
self.b = b
self.a = a
}
}
// MARK: - Luminance
extension RGBA {
/// The perceived luminance using ITU-R BT.601 coefficients.
///
/// Returns a value in the range 0.0 (black) to 255.0 (white).
public var luminance: Double {
Double(r) * 0.299 + Double(g) * 0.587 + Double(b) * 0.114
}
}
// MARK: - RGBAImage
/// A raw image stored as a flat array of RGBA pixels in row-major order.
///
/// This is the platform-independent representation produced by
/// `ImageLoader` implementations and consumed by `ASCIIConverter`.
public struct RGBAImage: Sendable {
/// Image width in pixels.
public let width: Int
/// Image height in pixels.
public let height: Int
/// Row-major pixel data (`width * height` elements).
public private(set) var pixels: [RGBA]
/// Creates an image from dimensions and pixel data.
///
/// - Parameters:
/// - width: Image width in pixels.
/// - height: Image height in pixels.
/// - pixels: Pixel data in row-major order. Must contain `width * height` elements.
public init(width: Int, height: Int, pixels: [RGBA]) {
precondition(pixels.count == width * height, "Pixel count must match width * height")
self.width = width
self.height = height
self.pixels = pixels
}
}
// MARK: - Pixel Access
extension RGBAImage {
/// Returns the pixel at the given coordinates.
///
/// - Parameters:
/// - x: Column (0-based, left to right).
/// - y: Row (0-based, top to bottom).
/// - Returns: The RGBA pixel value.
public func pixel(at x: Int, _ y: Int) -> RGBA {
pixels[y * width + x]
}
/// Sets the pixel at the given coordinates.
///
/// - Parameters:
/// - x: Column (0-based).
/// - y: Row (0-based).
/// - value: The new pixel value.
public mutating func setPixel(at x: Int, _ y: Int, value: RGBA) {
pixels[y * width + x] = value
}
/// Adds an error value to the pixel at the given coordinates (for dithering).
///
/// Clamps each channel to the valid 0-255 range.
///
/// - Parameters:
/// - x: Column.
/// - y: Row.
/// - rError: Red channel error.
/// - gError: Green channel error.
/// - bError: Blue channel error.
public mutating func addError(at x: Int, _ y: Int, rError: Double, gError: Double, bError: Double) {
let index = y * width + x
let pixel = pixels[index]
pixels[index] = RGBA(
r: UInt8(clamping: Int(Double(pixel.r) + rError)),
g: UInt8(clamping: Int(Double(pixel.g) + gError)),
b: UInt8(clamping: Int(Double(pixel.b) + bError))
)
}
}
// MARK: - Image Scaling
extension RGBAImage {
/// Returns a scaled copy using nearest-neighbor interpolation.
///
/// - Parameters:
/// - targetWidth: The desired width.
/// - targetHeight: The desired height.
/// - Returns: A new image with the specified dimensions.
public func scaled(to targetWidth: Int, _ targetHeight: Int) -> RGBAImage {
guard targetWidth > 0, targetHeight > 0 else {
return RGBAImage(width: 0, height: 0, pixels: [])
}
var result = [RGBA](repeating: RGBA(r: 0, g: 0, b: 0), count: targetWidth * targetHeight)
for y in 0..<targetHeight {
let srcY = y * height / targetHeight
for x in 0..<targetWidth {
let srcX = x * width / targetWidth
result[y * targetWidth + x] = pixel(at: srcX, srcY)
}
}
return RGBAImage(width: targetWidth, height: targetHeight, pixels: result)
}
/// Returns a scaled copy using bilinear interpolation for smoother results.
///
/// - Parameters:
/// - targetWidth: The desired width.
/// - targetHeight: The desired height.
/// - Returns: A new image with the specified dimensions.
public func scaledBilinear(to targetWidth: Int, _ targetHeight: Int) -> RGBAImage {
guard targetWidth > 0, targetHeight > 0 else {
return RGBAImage(width: 0, height: 0, pixels: [])
}
var result = [RGBA](repeating: RGBA(r: 0, g: 0, b: 0), count: targetWidth * targetHeight)
let xRatio = Double(width) / Double(targetWidth)
let yRatio = Double(height) / Double(targetHeight)
for y in 0..<targetHeight {
let srcY = Double(y) * yRatio
let y0 = min(Int(srcY), height - 1)
let y1 = min(y0 + 1, height - 1)
let yFrac = srcY - Double(y0)
for x in 0..<targetWidth {
let srcX = Double(x) * xRatio
let x0 = min(Int(srcX), width - 1)
let x1 = min(x0 + 1, width - 1)
let xFrac = srcX - Double(x0)
let p00 = pixel(at: x0, y0)
let p10 = pixel(at: x1, y0)
let p01 = pixel(at: x0, y1)
let p11 = pixel(at: x1, y1)
let r = bilinearInterpolate(
Double(p00.r), Double(p10.r), Double(p01.r), Double(p11.r), xFrac, yFrac
)
let g = bilinearInterpolate(
Double(p00.g), Double(p10.g), Double(p01.g), Double(p11.g), xFrac, yFrac
)
let b = bilinearInterpolate(
Double(p00.b), Double(p10.b), Double(p01.b), Double(p11.b), xFrac, yFrac
)
result[y * targetWidth + x] = RGBA(
r: UInt8(clamping: Int(r.rounded())),
g: UInt8(clamping: Int(g.rounded())),
b: UInt8(clamping: Int(b.rounded()))
)
}
}
return RGBAImage(width: targetWidth, height: targetHeight, pixels: result)
}
}
// MARK: - Private Helpers
extension RGBAImage {
private func bilinearInterpolate(
_ v00: Double, _ v10: Double, _ v01: Double, _ v11: Double,
_ xFrac: Double, _ yFrac: Double
) -> Double {
let top = v00 * (1.0 - xFrac) + v10 * xFrac
let bottom = v01 * (1.0 - xFrac) + v11 * xFrac
return top * (1.0 - yFrac) + bottom * yFrac
}
}