Feat: Add Image view with ASCII art rendering, bracketed paste, and input filtering

- Add Image view rendering local files and URLs as colored ASCII art
- Add CSTBImage C target wrapping stb_image for cross-platform image decoding
- Add ASCIIConverter with block, ASCII, and braille character sets
- Support trueColor, ANSI-256, grayscale, and mono color modes
- Add Floyd-Steinberg dithering for improved visual quality
- Add async image loading with URLImageCache for URL sources
- Add bracketed paste mode for bulk text insertion in text fields
- Add TextContentType modifier for input character filtering
- Add ContentMode enum and aspectRatio(_:contentMode:) View modifier
- Add text-input priority in key dispatch to prevent shortcut conflicts
- Add Image (File) and Image (URL) demo pages to example app
- Update DocC documentation with new symbols and layout table
This commit is contained in:
phranck
2026-02-14 00:43:22 +01:00
parent c75168a314
commit b3d563040a
28 changed files with 10411 additions and 49 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"originHash" : "20a49b01b0e08a4eba6bdf90e830aa1c43301d8c17142c3e986cd34a9ed67d16",
"originHash" : "978155e2813f61182dd53d12465d831a26121bd7f15b38bea29cc7abc323457b",
"pins" : [
{
"identity" : "swift-docc-plugin",
+10 -2
View File
@@ -27,11 +27,19 @@ let package = Package(
],
targets: [
.target(
name: "TUIkit"
name: "CSTBImage",
publicHeadersPath: "include"
),
.target(
name: "TUIkit",
dependencies: ["CSTBImage"]
),
.executableTarget(
name: "TUIkitExample",
dependencies: ["TUIkit"]
dependencies: ["TUIkit"],
resources: [
.copy("Resources"),
]
),
.testTarget(
name: "TUIkitTests",
@@ -0,0 +1,4 @@
module CSTBImage {
header "stb_image.h"
export *
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
// stb_image implementation file
// This activates the implementation of stb_image.h (single-header library).
// Public domain / MIT license - see stb_image.h for details.
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
+4 -3
View File
@@ -174,10 +174,11 @@ extension AppRunner {
}
// Read key events (non-blocking with VTIME=0)
// Process multiple events per frame to prevent input buffering lag,
// but limit to avoid render starvation and keep CPU usage low.
// Process all available events per frame. A high limit prevents
// input buffering lag during paste operations while still avoiding
// infinite loops if input arrives faster than we can process.
var eventsProcessed = 0
let maxEventsPerFrame = 5
let maxEventsPerFrame = 128
while eventsProcessed < maxEventsPerFrame,
let keyEvent = terminal.readKeyEvent() {
inputHandler.handle(keyEvent)
+18 -2
View File
@@ -42,6 +42,19 @@ extension InputHandler {
///
/// - Parameter event: The key event to handle.
func handle(_ event: KeyEvent) {
// Text-Input Priority: when a text-input element (TextField/SecureField)
// is focused, let it handle the event FIRST. This ensures printable
// characters, backspace, delete, arrows, home, end, and enter reach the
// text field before any other layer can intercept them.
//
// Only structural/navigation keys that the text field does NOT consume
// (Escape, Tab, unhandled Ctrl+shortcuts) fall through to other layers.
if focusManager.hasTextInputFocus {
if focusManager.dispatchKeyEvent(event) {
return
}
}
// Layer 1: Status bar items with actions
if statusBar.handleKeyEvent(event) {
return
@@ -53,8 +66,11 @@ extension InputHandler {
}
// Layer 3: Focus system (Tab navigation, Enter/Space on focused buttons)
if focusManager.dispatchKeyEvent(event) {
return
// Skipped when text-input has focus since it was already dispatched above.
if !focusManager.hasTextInputFocus {
if focusManager.dispatchKeyEvent(event) {
return
}
}
// Layer 4: Default key bindings
+3
View File
@@ -69,6 +69,9 @@ public enum Key: Hashable, Sendable {
// Character key
case character(Character)
// Bracketed paste (bulk text from terminal paste operation)
case paste(String)
/// Creates a Key from a character if it's a simple character.
public static func from(_ char: Character) -> Self {
.character(char)
+8
View File
@@ -141,6 +141,14 @@ public final class FocusManager: @unchecked Sendable {
public var currentFocusedID: String? {
focusedID
}
/// Whether the currently focused element is a text-input handler.
///
/// When `true`, the input handler should give the focused element
/// priority for key events before dispatching to other layers.
var hasTextInputFocus: Bool {
currentFocused is TextFieldHandler
}
}
// MARK: - Public API
+37 -4
View File
@@ -74,6 +74,13 @@ final class TextFieldHandler: Focusable {
/// Callback triggered when the user presses Enter.
var onSubmit: (() -> Void)?
/// The text content type used for input character filtering.
///
/// When set, both typed characters and pasted text are filtered against
/// the allowed character set of the content type. Synced from the
/// environment during each render pass.
var textContentType: TextContentType?
/// Undo history stack storing previous text states and cursor positions.
private var undoStack: [(text: String, cursor: Int)] = []
@@ -292,6 +299,10 @@ extension TextFieldHandler {
onSubmit?()
return true
case .paste(let text):
insertText(text)
return true
default:
return false
}
@@ -307,6 +318,8 @@ extension TextFieldHandler {
///
/// - Parameter char: The character to insert.
func insertCharacter(_ char: Character) {
guard textContentType?.isAllowed(char) ?? true else { return }
pushUndoState()
// Replace selection if present
@@ -466,7 +479,27 @@ extension TextFieldHandler {
/// Uses `pbpaste` on macOS. Replaces selection if any.
func paste() {
guard let pastedText = pasteFromClipboard() else { return }
guard !pastedText.isEmpty else { return }
insertText(pastedText)
}
/// Inserts a string at the cursor position in a single operation.
///
/// Used by both clipboard paste (`Ctrl+V`) and bracketed paste
/// (terminal paste via `Cmd+V`). Replaces selection if any.
///
/// - Parameter string: The text to insert.
func insertText(_ string: String) {
guard !string.isEmpty else { return }
// For single-line text fields, strip newlines from pasted text.
var sanitized = string.replacingOccurrences(of: "\n", with: "")
.replacingOccurrences(of: "\r", with: "")
// Filter by content type if set.
if let contentType = textContentType {
sanitized = contentType.filterString(sanitized)
}
guard !sanitized.isEmpty else { return }
pushUndoState()
@@ -476,12 +509,12 @@ extension TextFieldHandler {
clearSelection()
}
// Insert pasted text
// Insert text
var current = text.wrappedValue
let index = current.index(current.startIndex, offsetBy: min(cursorPosition, current.count))
current.insert(contentsOf: pastedText, at: index)
current.insert(contentsOf: sanitized, at: index)
text.wrappedValue = current
cursorPosition += pastedText.count
cursorPosition += sanitized.count
}
}
+498
View File
@@ -0,0 +1,498 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// ASCIIConverter.swift
//
// Created by LAYERED.work
// License: MIT
// MARK: - Character Set
/// The set of characters used for ASCII art rendering.
///
/// Each set trades off between compatibility and visual quality.
public enum ASCIICharacterSet: Sendable, Equatable {
/// Standard ASCII characters (10 levels). Works in every terminal.
case ascii
/// Unicode block elements (5 levels). Requires Unicode support.
case blocks
/// Unicode Braille patterns (2x4 pixel cells, 256 patterns). Highest resolution.
case braille
}
// MARK: - Color Mode
/// Controls how colors are rendered in ASCII art output.
public enum ASCIIColorMode: Sendable, Equatable {
/// 24-bit RGB using `\e[38;2;R;G;B` sequences. Best quality.
case trueColor
/// 256-color ANSI palette. Good terminal compatibility.
case ansi256
/// 24 shades of gray.
case grayscale
/// Black and white only. Universal compatibility.
case mono
}
// MARK: - Dithering Mode
/// The dithering algorithm applied during color quantization.
public enum DitheringMode: Sendable, Equatable {
/// Floyd-Steinberg error diffusion. Good for smooth gradients.
case floydSteinberg
/// No dithering. Fastest.
case none
}
// MARK: - ASCII Converter
/// Converts an `RGBAImage` to colored ASCII art strings.
///
/// The conversion pipeline:
/// 1. Scale image to target character dimensions
/// 2. Apply aspect ratio correction (terminal chars are ~2:1)
/// 3. Optionally apply dithering
/// 4. Map each pixel to a character based on luminance
/// 5. Colorize each character using the selected color mode
struct ASCIIConverter: Sendable {
/// The character set to use for brightness mapping.
let characterSet: ASCIICharacterSet
/// The color mode for output.
let colorMode: ASCIIColorMode
/// The dithering algorithm (nil or .none means no dithering).
let dithering: DitheringMode
/// Creates a converter with the specified options.
init(
characterSet: ASCIICharacterSet = .blocks,
colorMode: ASCIIColorMode = .trueColor,
dithering: DitheringMode = .none
) {
self.characterSet = characterSet
self.colorMode = colorMode
self.dithering = dithering
}
}
// MARK: - Conversion
extension ASCIIConverter {
/// Converts an image to an array of ANSI-colored strings (one per row).
///
/// - Parameters:
/// - image: The source image.
/// - width: Target width in characters.
/// - height: Target height in characters.
/// - Returns: An array of ANSI-formatted strings representing the ASCII art.
func convert(_ image: RGBAImage, width: Int, height: Int) -> [String] {
guard image.width > 0, image.height > 0, width > 0, height > 0 else {
return []
}
// For braille, each character cell covers 2x4 pixels.
let pixelWidth: Int
let pixelHeight: Int
if characterSet == .braille {
pixelWidth = width * 2
pixelHeight = height * 4
} else {
pixelWidth = width
pixelHeight = height
}
// Scale image to target pixel dimensions
var scaled = image.scaledBilinear(to: pixelWidth, pixelHeight)
// Apply dithering if requested (only meaningful for non-trueColor modes)
if dithering == .floydSteinberg, colorMode != .trueColor {
scaled = applyFloydSteinbergDithering(scaled)
}
// Convert to ASCII lines
if characterSet == .braille {
return convertBraille(scaled, width: width, height: height)
}
return convertCharacterBased(scaled, width: width, height: height)
}
}
// MARK: - Character-Based Conversion
extension ASCIIConverter {
/// Converts using character brightness mapping (ascii, blocks).
private func convertCharacterBased(_ image: RGBAImage, width: Int, height: Int) -> [String] {
let ramp = characterRamp
var lines = [String]()
lines.reserveCapacity(height)
for y in 0..<height {
var line = ""
line.reserveCapacity(width * 20) // Reserve for ANSI codes
var lastColor = ""
for x in 0..<width {
let pixel = image.pixel(at: x, y)
// Map luminance to character
let charIndex = Int((pixel.luminance / 255.0) * Double(ramp.count - 1))
let clampedIndex = min(max(charIndex, 0), ramp.count - 1)
let char = ramp[clampedIndex]
// Colorize
let colorCode = foregroundColorCode(for: pixel)
if colorCode != lastColor {
if !lastColor.isEmpty {
line += ANSIRenderer.reset
}
line += colorCode
lastColor = colorCode
}
line.append(char)
}
if !lastColor.isEmpty {
line += ANSIRenderer.reset
}
lines.append(line)
}
return lines
}
/// The character ramp for the current character set, from darkest to brightest.
private var characterRamp: [Character] {
switch characterSet {
case .ascii:
return Array(" .:;+=xX$@")
case .blocks:
return Array(" ░▒▓█")
case .braille:
// Not used directly; braille has its own rendering path
return Array(" ⠁⠃⠇⡇⣇⣧⣷⣿")
}
}
}
// MARK: - Braille Conversion
extension ASCIIConverter {
/// Converts using 2x4 Braille character cells for maximum resolution.
///
/// Each Braille character (U+2800-U+28FF) represents a 2x4 pixel grid.
/// The dot pattern encodes which pixels are "on" based on a luminance threshold.
/// Color is taken from the average of the cell's pixels.
private func convertBraille(_ image: RGBAImage, width: Int, height: Int) -> [String] {
// Braille dot positions (column, row) -> bit index
// = bit 0 (0,0) = bit 3 (1,0)
// = bit 1 (0,1) = bit 4 (1,1)
// = bit 2 (0,2) = bit 5 (1,2)
// = bit 6 (0,3) = bit 7 (1,3)
let dotBits: [[Int]] = [
[0, 3], // row 0: left=bit0, right=bit3
[1, 4], // row 1: left=bit1, right=bit4
[2, 5], // row 2: left=bit2, right=bit5
[6, 7], // row 3: left=bit6, right=bit7
]
let threshold = 128.0
var lines = [String]()
lines.reserveCapacity(height)
for charY in 0..<height {
var line = ""
line.reserveCapacity(width * 20)
var lastColor = ""
for charX in 0..<width {
let pixelX = charX * 2
let pixelY = charY * 4
var pattern: UInt8 = 0
var totalR = 0, totalG = 0, totalB = 0
var count = 0
for dy in 0..<4 {
for dx in 0..<2 {
let px = pixelX + dx
let py = pixelY + dy
guard px < image.width, py < image.height else { continue }
let pixel = image.pixel(at: px, py)
totalR += Int(pixel.r)
totalG += Int(pixel.g)
totalB += Int(pixel.b)
count += 1
if pixel.luminance >= threshold {
pattern |= 1 << dotBits[dy][dx]
}
}
}
// Braille character: U+2800 + pattern
let brailleChar = Character(Unicode.Scalar(0x2800 + UInt32(pattern))!)
// Average color for this cell
let avgPixel: RGBA
if count > 0 {
avgPixel = RGBA(
r: UInt8(clamping: totalR / count),
g: UInt8(clamping: totalG / count),
b: UInt8(clamping: totalB / count)
)
} else {
avgPixel = RGBA(r: 0, g: 0, b: 0)
}
let colorCode = foregroundColorCode(for: avgPixel)
if colorCode != lastColor {
if !lastColor.isEmpty {
line += ANSIRenderer.reset
}
line += colorCode
lastColor = colorCode
}
line.append(brailleChar)
}
if !lastColor.isEmpty {
line += ANSIRenderer.reset
}
lines.append(line)
}
return lines
}
}
// MARK: - Color Output
extension ASCIIConverter {
/// Returns the ANSI foreground color escape code for a pixel.
private func foregroundColorCode(for pixel: RGBA) -> String {
switch colorMode {
case .trueColor:
return "\(ANSIRenderer.csi)38;2;\(pixel.r);\(pixel.g);\(pixel.b)m"
case .ansi256:
let index = quantizeToANSI256(pixel)
return "\(ANSIRenderer.csi)38;5;\(index)m"
case .grayscale:
let gray = Int(pixel.luminance / 255.0 * 23.0)
let index = 232 + min(max(gray, 0), 23)
return "\(ANSIRenderer.csi)38;5;\(index)m"
case .mono:
return ""
}
}
/// Quantizes an RGB pixel to the nearest ANSI 256-color index.
private func quantizeToANSI256(_ pixel: RGBA) -> UInt8 {
// Check for near-grayscale
let rDiff = abs(Int(pixel.r) - Int(pixel.g))
let gDiff = abs(Int(pixel.g) - Int(pixel.b))
if rDiff < 10, gDiff < 10 {
let gray = Int(pixel.r)
if gray < 8 { return 16 }
if gray > 248 { return 231 }
return UInt8(232 + (gray - 8) / 10)
}
// 6x6x6 color cube (indices 16-231)
let r = Int((Double(pixel.r) / 255.0 * 5.0).rounded())
let g = Int((Double(pixel.g) / 255.0 * 5.0).rounded())
let b = Int((Double(pixel.b) / 255.0 * 5.0).rounded())
return UInt8(16 + 36 * r + 6 * g + b)
}
}
// MARK: - Floyd-Steinberg Dithering
extension ASCIIConverter {
/// Applies Floyd-Steinberg error diffusion dithering.
///
/// Distributes quantization error to neighboring pixels:
/// - Right: 7/16
/// - Bottom-left: 3/16
/// - Bottom: 5/16
/// - Bottom-right: 1/16
private func applyFloydSteinbergDithering(_ image: RGBAImage) -> RGBAImage {
var result = image
for y in 0..<image.height {
for x in 0..<image.width {
let oldPixel = result.pixel(at: x, y)
let newPixel = quantizePixel(oldPixel)
result.setPixel(at: x, y, value: newPixel)
let rErr = Double(oldPixel.r) - Double(newPixel.r)
let gErr = Double(oldPixel.g) - Double(newPixel.g)
let bErr = Double(oldPixel.b) - Double(newPixel.b)
// Distribute error to neighbors
if x + 1 < image.width {
result.addError(at: x + 1, y,
rError: rErr * 7.0 / 16.0,
gError: gErr * 7.0 / 16.0,
bError: bErr * 7.0 / 16.0)
}
if y + 1 < image.height {
if x > 0 {
result.addError(at: x - 1, y + 1,
rError: rErr * 3.0 / 16.0,
gError: gErr * 3.0 / 16.0,
bError: bErr * 3.0 / 16.0)
}
result.addError(at: x, y + 1,
rError: rErr * 5.0 / 16.0,
gError: gErr * 5.0 / 16.0,
bError: bErr * 5.0 / 16.0)
if x + 1 < image.width {
result.addError(at: x + 1, y + 1,
rError: rErr * 1.0 / 16.0,
gError: gErr * 1.0 / 16.0,
bError: bErr * 1.0 / 16.0)
}
}
}
}
return result
}
/// Quantizes a pixel to its nearest representative value for the current color mode.
private func quantizePixel(_ pixel: RGBA) -> RGBA {
switch colorMode {
case .trueColor:
return pixel
case .ansi256:
let index = quantizeToANSI256(pixel)
return ansi256ToRGB(index)
case .grayscale:
let gray = UInt8(clamping: Int(pixel.luminance))
return RGBA(r: gray, g: gray, b: gray)
case .mono:
let val: UInt8 = pixel.luminance > 128.0 ? 255 : 0
return RGBA(r: val, g: val, b: val)
}
}
/// Converts an ANSI 256-color index back to approximate RGB.
private func ansi256ToRGB(_ index: UInt8) -> RGBA {
let idx = Int(index)
if idx < 16 {
// Standard colors (approximate)
let table: [(UInt8, UInt8, UInt8)] = [
(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0),
(0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192),
(128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0),
(0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255),
]
let (r, g, b) = table[idx]
return RGBA(r: r, g: g, b: b)
} else if idx < 232 {
// 6x6x6 color cube
let offset = idx - 16
let r = offset / 36
let g = (offset % 36) / 6
let b = offset % 6
return RGBA(
r: r == 0 ? 0 : UInt8(55 + r * 40),
g: g == 0 ? 0 : UInt8(55 + g * 40),
b: b == 0 ? 0 : UInt8(55 + b * 40)
)
} else {
// Grayscale ramp
let gray = UInt8(8 + (idx - 232) * 10)
return RGBA(r: gray, g: gray, b: gray)
}
}
}
// MARK: - Aspect Ratio
extension ASCIIConverter {
/// Calculates the target character dimensions preserving aspect ratio.
///
/// Terminal characters are approximately 2:1 (height:width), so the
/// vertical dimension is halved to compensate.
///
/// - Parameters:
/// - imageWidth: Source image width in pixels.
/// - imageHeight: Source image height in pixels.
/// - maxWidth: Maximum width in characters.
/// - maxHeight: Maximum height in characters (optional).
/// - contentMode: Whether to fit within or fill the available bounds.
/// - overrideAspectRatio: An explicit width/height ratio. When `nil`,
/// the source image's natural ratio is used.
/// - Returns: The target width and height in characters.
static func targetSize(
imageWidth: Int,
imageHeight: Int,
maxWidth: Int,
maxHeight: Int? = nil,
contentMode: ContentMode = .fit,
overrideAspectRatio: Double? = nil
) -> (width: Int, height: Int) {
let terminalAspect = 2.0 // Terminal chars are ~2x taller than wide
// Use override ratio or compute from source dimensions.
let sourceRatio = overrideAspectRatio
?? (Double(imageWidth) / Double(imageHeight))
// correctedRatio accounts for terminal character aspect (tall cells).
let correctedRatio = sourceRatio * terminalAspect
let maxH = maxHeight ?? Int((Double(maxWidth) / correctedRatio).rounded())
let targetWidth: Int
let targetHeight: Int
switch contentMode {
case .fit:
// Scale to fit within both bounds. Result <= bounds.
let widthFromHeight = Int((Double(maxH) * correctedRatio).rounded())
if widthFromHeight <= maxWidth {
targetWidth = widthFromHeight
targetHeight = maxH
} else {
targetWidth = maxWidth
targetHeight = Int((Double(maxWidth) / correctedRatio).rounded())
}
case .fill:
// Scale so the shorter dimension fills its bound.
// Result may exceed one bound.
let widthFromHeight = Int((Double(maxH) * correctedRatio).rounded())
if widthFromHeight >= maxWidth {
targetWidth = widthFromHeight
targetHeight = maxH
} else {
targetWidth = maxWidth
targetHeight = Int((Double(maxWidth) / correctedRatio).rounded())
}
}
return (width: max(1, targetWidth), height: max(1, targetHeight))
}
}
+207
View File
@@ -0,0 +1,207 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// ImageLoader.swift
//
// Created by LAYERED.work
// License: MIT
import CSTBImage
import Foundation
// MARK: - ImageLoader Protocol
/// Loads images from file paths or raw data and converts them to `RGBAImage`.
///
/// Uses stb_image (bundled C library) on all platforms for consistent behavior.
/// Supported formats: PNG, JPEG, GIF, BMP, TGA, HDR, PSD, PNM.
protocol ImageLoader: Sendable {
/// Loads an image from a file path.
///
/// - Parameter path: The absolute file path to the image.
/// - Returns: The decoded image as `RGBAImage`.
/// - Throws: `ImageLoadError` if the file cannot be read or decoded.
func loadImage(from path: String) throws -> RGBAImage
/// Loads an image from raw data.
///
/// - Parameter data: The image file data.
/// - Returns: The decoded image as `RGBAImage`.
/// - Throws: `ImageLoadError` if the data cannot be decoded.
func loadImage(from data: Data) throws -> RGBAImage
}
// MARK: - ImageLoadError
/// Errors that can occur during image loading.
enum ImageLoadError: Error, LocalizedError, CustomStringConvertible {
/// The file was not found at the given path.
case fileNotFound(String)
/// The image format is not supported.
case unsupportedFormat(String)
/// The image data could not be decoded.
case decodingFailed(String)
/// A URL download failed.
case downloadFailed(String)
var description: String {
switch self {
case .fileNotFound(let path):
return "Image file not found: \(path)"
case .unsupportedFormat(let format):
return "Unsupported image format: \(format)"
case .decodingFailed(let reason):
return "Image decoding failed: \(reason)"
case .downloadFailed(let reason):
return "Image download failed: \(reason)"
}
}
var errorDescription: String? { description }
}
// MARK: - Platform Image Loader
/// Cross-platform image loader using stb_image.
///
/// Supports PNG, JPEG, GIF, BMP, TGA, HDR, PSD, and PNM formats
/// on both macOS and Linux. stb_image is a public-domain single-header
/// C library bundled as a local `CSTBImage` target.
struct PlatformImageLoader: ImageLoader {
func loadImage(from path: String) throws -> RGBAImage {
guard FileManager.default.fileExists(atPath: path) else {
throw ImageLoadError.fileNotFound(path)
}
var width: Int32 = 0
var height: Int32 = 0
var channels: Int32 = 0
guard let rawPixels = stbi_load(path, &width, &height, &channels, 4) else {
let reason = String(cString: stbi_failure_reason())
throw ImageLoadError.decodingFailed("stb_image: \(reason)")
}
defer { stbi_image_free(rawPixels) }
return pixelsFromRaw(rawPixels, width: Int(width), height: Int(height))
}
func loadImage(from data: Data) throws -> RGBAImage {
var width: Int32 = 0
var height: Int32 = 0
var channels: Int32 = 0
let rawPixels: UnsafeMutablePointer<UInt8>? = data.withUnsafeBytes { buffer in
guard let baseAddress = buffer.baseAddress else { return nil }
return stbi_load_from_memory(
baseAddress.assumingMemoryBound(to: UInt8.self),
Int32(data.count),
&width,
&height,
&channels,
4
)
}
guard let pixels = rawPixels else {
let reason = String(cString: stbi_failure_reason())
throw ImageLoadError.decodingFailed("stb_image: \(reason)")
}
defer { stbi_image_free(pixels) }
return pixelsFromRaw(pixels, width: Int(width), height: Int(height))
}
}
// MARK: - Private Helpers
extension PlatformImageLoader {
/// Converts raw stb_image RGBA output to an `RGBAImage`.
private func pixelsFromRaw(
_ rawPixels: UnsafeMutablePointer<UInt8>,
width: Int,
height: Int
) -> RGBAImage {
let count = width * height
var pixels = [RGBA](repeating: RGBA(r: 0, g: 0, b: 0), count: count)
for i in 0..<count {
let offset = i * 4
pixels[i] = RGBA(
r: rawPixels[offset],
g: rawPixels[offset + 1],
b: rawPixels[offset + 2],
a: rawPixels[offset + 3]
)
}
return RGBAImage(width: width, height: height, pixels: pixels)
}
}
// MARK: - URL Image Cache
/// A session-scoped cache for images downloaded from URLs.
///
/// Cached entries persist for the lifetime of the application.
/// Thread-safe via an internal lock.
final class URLImageCache: @unchecked Sendable {
/// Shared session cache.
static let shared = URLImageCache()
private var cache: [String: RGBAImage] = [:]
private let lock = NSLock()
private init() {}
/// Returns a cached image for the given URL string, or nil.
func get(_ urlString: String) -> RGBAImage? {
lock.lock()
defer { lock.unlock() }
return cache[urlString]
}
/// Stores an image in the cache for the given URL string.
func set(_ urlString: String, image: RGBAImage) {
lock.lock()
defer { lock.unlock() }
cache[urlString] = image
}
}
// MARK: - URL Image Loading
extension PlatformImageLoader {
/// Loads an image from a URL, using the session cache.
///
/// On first access the image is downloaded synchronously and cached.
/// Subsequent calls for the same URL return the cached copy.
///
/// - Parameter urlString: The URL to download.
/// - Returns: The decoded image.
/// - Throws: `ImageLoadError` on network or decoding failure.
func loadImage(from urlString: String, cache: URLImageCache = .shared) throws -> RGBAImage {
if let cached = cache.get(urlString) {
return cached
}
guard let url = URL(string: urlString) else {
throw ImageLoadError.downloadFailed("Invalid URL: \(urlString)")
}
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw ImageLoadError.downloadFailed(error.localizedDescription)
}
let image = try loadImage(from: data)
cache.set(urlString, image: image)
return image
}
}
+209
View File
@@ -0,0 +1,209 @@
// 🖥 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).
struct RGBA: Sendable, Equatable {
var r: UInt8
var g: UInt8
var b: UInt8
var a: UInt8
/// Creates an opaque pixel with the given RGB values.
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).
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`.
struct RGBAImage: Sendable {
/// Image width in pixels.
let width: Int
/// Image height in pixels.
let height: Int
/// Row-major pixel data (`width * height` elements).
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.
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.
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.
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.
mutating func addError(at x: Int, _ y: Int, rError: Double, gError: Double, bError: Double) {
let index = y * width + x
let p = pixels[index]
pixels[index] = RGBA(
r: UInt8(clamping: Int(Double(p.r) + rError)),
g: UInt8(clamping: Int(Double(p.g) + gError)),
b: UInt8(clamping: Int(Double(p.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.
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.
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
}
}
+64
View File
@@ -138,11 +138,21 @@ extension Terminal {
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw)
isRawMode = true
// Enable bracketed paste mode so that terminal paste operations
// are wrapped in ESC[200~ ... ESC[201~ markers. This allows the
// application to detect pasted text and insert it as a single
// bulk operation instead of processing each character individually.
writeImmediate("\u{1B}[?2004h")
}
/// Disables raw mode and restores normal terminal operation.
func disableRawMode() {
guard isRawMode, var original = originalTermios else { return }
// Disable bracketed paste mode before restoring terminal state.
writeImmediate("\u{1B}[?2004l")
tcsetattr(STDIN_FILENO, TCSAFLUSH, &original)
isRawMode = false
}
@@ -270,12 +280,66 @@ extension Terminal {
/// Reads a key event from the terminal.
///
/// When bracketed paste mode is active the terminal wraps pasted text
/// in `ESC[200~` ... `ESC[201~` markers. This method detects the start
/// marker, buffers all bytes until the end marker, and returns the
/// entire pasted text as a single `Key.paste(String)` event.
///
/// - Returns: The key event, or nil on timeout/error.
func readKeyEvent() -> KeyEvent? {
let bytes = readBytes()
guard !bytes.isEmpty else { return nil }
// Detect bracketed paste start: ESC [ 2 0 0 ~
if bytes == [0x1B, 0x5B, 0x32, 0x30, 0x30, 0x7E] {
let pastedText = readBracketedPasteContent()
return KeyEvent(key: .paste(pastedText))
}
return KeyEvent.parse(bytes)
}
/// Reads bytes until the bracketed paste end marker `ESC[201~` is found.
///
/// Called after the paste start marker `ESC[200~` has been detected.
/// Reads byte-by-byte, watching for the 6-byte end sequence. All bytes
/// before the end marker are collected and returned as a UTF-8 string.
///
/// - Returns: The pasted text content.
private func readBracketedPasteContent() -> String {
var content: [UInt8] = []
// The end marker is: ESC [ 2 0 1 ~
let endMarker: [UInt8] = [0x1B, 0x5B, 0x32, 0x30, 0x31, 0x7E]
// Safety limit to prevent infinite buffering on malformed input.
let maxPasteBytes = 65_536
while content.count < maxPasteBytes {
var byte = [UInt8](repeating: 0, count: 1)
let bytesRead = read(STDIN_FILENO, &byte, 1)
guard bytesRead > 0 else {
// No more data available right now. For non-blocking reads
// (VMIN=0, VTIME=0) this means the paste end marker has not
// yet arrived. Wait briefly and retry.
usleep(1_000) // 1ms
continue
}
content.append(byte[0])
// Check if content ends with the paste end marker.
if content.count >= endMarker.count {
let tail = Array(content.suffix(endMarker.count))
if tail == endMarker {
// Remove the end marker from the content.
content.removeLast(endMarker.count)
break
}
}
}
return String(bytes: content, encoding: .utf8) ?? String(content.map { Character(UnicodeScalar($0)) })
}
}
// MARK: - Private Helpers
+38
View File
@@ -0,0 +1,38 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// ContentMode.swift
//
// Created by LAYERED.work
// License: MIT
import Foundation
// MARK: - ContentMode
/// Constants that define how a view's content fills the available space.
///
/// Use `ContentMode` with the ``View/aspectRatio(_:contentMode:)`` modifier
/// to control how an image or other content is scaled within its bounds.
///
/// - ``fit``: Scales content to fit within the bounds while preserving
/// the aspect ratio. The content may not fill the entire available space.
/// - ``fill``: Scales content to fill the bounds while preserving
/// the aspect ratio. The content may extend beyond the available space.
///
/// ## Usage
///
/// ```swift
/// Image(.file("photo.png"))
/// .aspectRatio(contentMode: .fit)
///
/// Image(.url("https://example.com/photo.png"))
/// .aspectRatio(16.0/9.0, contentMode: .fill)
/// ```
public enum ContentMode: Sendable, Equatable {
/// Scales content to fit within the parent by maintaining the
/// aspect ratio. The resulting dimensions are always within bounds.
case fit
/// Scales content to fill the parent by maintaining the aspect ratio.
/// The content may extend beyond the bounds along one dimension.
case fill
}
@@ -0,0 +1,209 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// TextContentType.swift
//
// Created by LAYERED.work
// License: MIT
import Foundation
// MARK: - TextContentType
/// Declares the semantic content type of a text field and filters input accordingly.
///
/// In SwiftUI, `UITextContentType` provides autofill hints to the system.
/// In a TUI there is no autofill, so `TextContentType` instead defines an
/// **allowed character set** for input filtering. Both typed characters and
/// pasted text are filtered against the allowed set; invalid characters are
/// silently dropped.
///
/// ## Usage
///
/// ```swift
/// // Only allow URL-valid characters
/// TextField("URL", text: $url)
/// .textContentType(.url)
///
/// // Only allow digits
/// TextField("Code", text: $code)
/// .textContentType(.oneTimeCode)
///
/// // Apply to all fields in a container
/// VStack {
/// TextField("User", text: $user)
/// SecureField("Password", text: $pass)
/// }
/// .textContentType(.username)
/// ```
///
/// ## Character Filtering
///
/// | Type | Allowed Characters |
/// |-------------------|-----------------------------------------------|
/// | `url` | Alphanumeric, `:/.?#[]@!$&'()*+,;=-_~%` |
/// | `emailAddress` | Alphanumeric, `@._+-` |
/// | `telephoneNumber` | `0-9`, `+()-. #*`, space |
/// | `username` | Alphanumeric, `._-@` |
/// | `password` | All characters (no filtering) |
/// | `oneTimeCode` | `0-9` |
/// | `integer` | `0-9`, `-` |
/// | `decimal` | `0-9`, `-.` |
public enum TextContentType: Sendable, Equatable {
/// URL input. Allows alphanumeric characters and URL-safe punctuation.
case url
/// Email address input. Allows alphanumeric characters, `@`, `.`, `_`, `+`, `-`.
case emailAddress
/// Telephone number input. Allows digits, `+`, `(`, `)`, `-`, `.`, `#`, `*`, space.
case telephoneNumber
/// Username input. Allows alphanumeric characters, `.`, `_`, `-`, `@`.
case username
/// Password input. Allows all characters (no filtering).
case password
/// One-time code input. Allows digits only.
case oneTimeCode
/// Integer input. Allows digits and `-` for negative numbers.
case integer
/// Decimal number input. Allows digits, `-`, and `.` for fractional numbers.
case decimal
}
// MARK: - Character Filtering
extension TextContentType {
/// The set of Unicode scalars allowed for this content type.
///
/// `.password` returns `nil` to indicate no filtering.
var allowedCharacters: CharacterSet? {
switch self {
case .url:
return Self.urlCharacters
case .emailAddress:
return Self.emailCharacters
case .telephoneNumber:
return Self.phoneCharacters
case .username:
return Self.usernameCharacters
case .password:
return nil
case .oneTimeCode:
return Self.digitCharacters
case .integer:
return Self.integerCharacters
case .decimal:
return Self.decimalCharacters
}
}
/// Whether the given character is allowed by this content type.
///
/// - Parameter character: The character to check.
/// - Returns: `true` if the character passes the filter, or if this type
/// has no filter (`.password`).
func isAllowed(_ character: Character) -> Bool {
guard let allowed = allowedCharacters else { return true }
return character.unicodeScalars.allSatisfy { allowed.contains($0) }
}
/// Filters a string, keeping only characters allowed by this content type.
///
/// - Parameter string: The input string to filter.
/// - Returns: A new string containing only the allowed characters.
func filterString(_ string: String) -> String {
guard allowedCharacters != nil else { return string }
return String(string.filter { isAllowed($0) })
}
}
// MARK: - Character Set Definitions
private extension TextContentType {
/// URL-safe characters per RFC 3986.
static let urlCharacters: CharacterSet = {
var set = CharacterSet.alphanumerics
set.insert(charactersIn: ":/.?#[]@!$&'()*+,;=-_~%")
return set
}()
/// Email address characters.
static let emailCharacters: CharacterSet = {
var set = CharacterSet.alphanumerics
set.insert(charactersIn: "@._+-")
return set
}()
/// Telephone number characters.
static let phoneCharacters: CharacterSet = {
var set = CharacterSet(charactersIn: "0123456789")
set.insert(charactersIn: "+()-. #*")
set.insert(charactersIn: " ")
return set
}()
/// Username characters.
static let usernameCharacters: CharacterSet = {
var set = CharacterSet.alphanumerics
set.insert(charactersIn: "._-@")
return set
}()
/// Digits only.
static let digitCharacters = CharacterSet(charactersIn: "0123456789")
/// Integer characters (digits and minus sign).
static let integerCharacters = CharacterSet(charactersIn: "0123456789-")
/// Decimal characters (digits, minus sign, and decimal point).
static let decimalCharacters = CharacterSet(charactersIn: "0123456789-.")
}
// MARK: - Environment Key
/// Environment key for the text content type.
private struct TextContentTypeKey: EnvironmentKey {
static let defaultValue: TextContentType? = nil
}
extension EnvironmentValues {
/// The text content type for text fields.
///
/// When set, text fields filter both typed characters and pasted text
/// against the allowed character set of the content type.
///
/// Set this value using the `.textContentType(_:)` modifier:
///
/// ```swift
/// TextField("URL", text: $url)
/// .textContentType(.url)
/// ```
public var textContentType: TextContentType? {
get { self[TextContentTypeKey.self] }
set { self[TextContentTypeKey.self] = newValue }
}
}
// MARK: - View Extension
extension View {
/// Sets the text content type for text fields within this view.
///
/// When a content type is set, both typed characters and pasted text
/// are filtered against the allowed character set. Invalid characters
/// are silently dropped.
///
/// ```swift
/// TextField("URL", text: $url)
/// .textContentType(.url)
/// ```
///
/// - Parameter type: The content type, or `nil` to disable filtering.
/// - Returns: A view with the content type applied.
public func textContentType(_ type: TextContentType?) -> some View {
environment(\.textContentType, type)
}
}
@@ -117,6 +117,7 @@ Flexible children share remaining space equally. If multiple spacers exist, they
| ``Slider`` | Yes | Width-flexible |
| ``Divider`` | Yes | Width-flexible |
| ``ProgressView`` | Yes | Width-flexible |
| ``Image`` | Yes | Both-flexible (fills available space) |
Views that are not `Layoutable` use the default implementation which renders first, then reports the buffer size as fixed.
+4
View File
@@ -74,6 +74,8 @@ struct MyApp: App {
- ``View``
- ``Text``
- ``Image``
- ``ImageSource``
- ``EmptyView``
- ``AnyView``
- ``Spinner``
@@ -153,6 +155,7 @@ struct MyApp: App {
- ``Appearance``
- ``BorderStyle``
- ``ContentMode``
- ``EdgeInsets``
- ``Edge``
@@ -169,6 +172,7 @@ struct MyApp: App {
- ``InsetGroupedListStyle``
- ``SpinnerStyle``
- ``TextCursorStyle``
- ``TextContentType``
- ``NavigationSplitViewStyle``
### View Composition
+265
View File
@@ -0,0 +1,265 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// Image.swift
//
// Created by LAYERED.work
// License: MIT
import Foundation
// MARK: - Image Source
/// Describes where to load an image from.
public enum ImageSource: Sendable, Equatable {
/// Load from a local file path.
case file(String)
/// Load from a URL (cached per session).
case url(String)
}
// MARK: - Image Loading Phase
/// Represents the current state of an async image loading operation.
enum ImageLoadingPhase: Sendable {
/// Loading has not started yet or is in progress.
case loading
/// The raw image was successfully loaded and is ready for conversion.
case success(RGBAImage)
/// Loading failed with an error.
case failure(String)
}
// MARK: - Image
/// Displays an image as colored ASCII art in the terminal.
///
/// `Image` loads a raster image from a file path or URL, converts it to
/// colored ASCII characters, and displays it at the specified size.
/// Loading happens asynchronously; a placeholder is shown while loading.
///
/// ## Usage
///
/// ```swift
/// // From a local file
/// Image(.file("/path/to/logo.png"))
/// .frame(width: 60, height: 30)
///
/// // From a URL (cached per session)
/// Image(.url("https://example.com/photo.png"))
/// .frame(width: 40, height: 20)
///
/// // With rendering options
/// Image(.file("photo.png"))
/// .imageCharacterSet(.braille)
/// .imageColorMode(.trueColor)
/// .imageDithering(.floydSteinberg)
/// .frame(width: 80, height: 40)
/// ```
///
/// ## Placeholder
///
/// While loading, a centered placeholder is displayed. By default this is
/// a ``Spinner``. Use ``View/imagePlaceholder(_:)`` to customize.
public struct Image: View {
/// The image source (file path or URL).
let source: ImageSource
/// Creates an image from the given source.
///
/// - Parameter source: The image source (file or URL).
public init(_ source: ImageSource) {
self.source = source
}
public var body: some View {
_ImageCore(source: source)
}
}
// MARK: - Equatable
extension Image: Equatable {
nonisolated public static func == (lhs: Image, rhs: Image) -> Bool {
lhs.source == rhs.source
}
}
// MARK: - Environment Keys
/// Environment key for the ASCII character set used by Image.
private struct ImageCharacterSetKey: EnvironmentKey {
static let defaultValue: ASCIICharacterSet = .blocks
}
/// Environment key for the color mode used by Image.
private struct ImageColorModeKey: EnvironmentKey {
static let defaultValue: ASCIIColorMode = .trueColor
}
/// Environment key for the dithering mode used by Image.
private struct ImageDitheringKey: EnvironmentKey {
static let defaultValue: DitheringMode = .none
}
/// Environment key for the placeholder text shown while loading.
private struct ImagePlaceholderTextKey: EnvironmentKey {
static let defaultValue: String? = nil
}
/// Environment key controlling whether a spinner is shown while loading.
private struct ImagePlaceholderSpinnerKey: EnvironmentKey {
static let defaultValue: Bool = true
}
/// Environment key for the image content mode.
private struct ImageContentModeKey: EnvironmentKey {
static let defaultValue: ContentMode = .fit
}
/// Environment key for an explicit aspect ratio override.
private struct ImageAspectRatioKey: EnvironmentKey {
static let defaultValue: Double? = nil
}
// MARK: - EnvironmentValues
extension EnvironmentValues {
/// The character set for ASCII art rendering.
var imageCharacterSet: ASCIICharacterSet {
get { self[ImageCharacterSetKey.self] }
set { self[ImageCharacterSetKey.self] = newValue }
}
/// The color mode for ASCII art rendering.
var imageColorMode: ASCIIColorMode {
get { self[ImageColorModeKey.self] }
set { self[ImageColorModeKey.self] = newValue }
}
/// The dithering mode for ASCII art rendering.
var imageDithering: DitheringMode {
get { self[ImageDitheringKey.self] }
set { self[ImageDitheringKey.self] = newValue }
}
/// Custom placeholder text shown while loading (nil = no text).
var imagePlaceholderText: String? {
get { self[ImagePlaceholderTextKey.self] }
set { self[ImagePlaceholderTextKey.self] = newValue }
}
/// Whether to show a spinner in the placeholder.
var imagePlaceholderSpinner: Bool {
get { self[ImagePlaceholderSpinnerKey.self] }
set { self[ImagePlaceholderSpinnerKey.self] = newValue }
}
/// The content mode for image scaling.
var imageContentMode: ContentMode {
get { self[ImageContentModeKey.self] }
set { self[ImageContentModeKey.self] = newValue }
}
/// An explicit aspect ratio override for images (width/height).
///
/// When `nil`, the source image's natural aspect ratio is used.
var imageAspectRatio: Double? {
get { self[ImageAspectRatioKey.self] }
set { self[ImageAspectRatioKey.self] = newValue }
}
}
// MARK: - View Modifiers
extension View {
/// Sets the character set for ASCII art image rendering.
///
/// - Parameter characterSet: The character set to use.
/// - Returns: A modified view.
public func imageCharacterSet(_ characterSet: ASCIICharacterSet) -> some View {
environment(\.imageCharacterSet, characterSet)
}
/// Sets the color mode for ASCII art image rendering.
///
/// - Parameter colorMode: The color mode to use.
/// - Returns: A modified view.
public func imageColorMode(_ colorMode: ASCIIColorMode) -> some View {
environment(\.imageColorMode, colorMode)
}
/// Sets the dithering mode for ASCII art image rendering.
///
/// - Parameter dithering: The dithering algorithm.
/// - Returns: A modified view.
public func imageDithering(_ dithering: DitheringMode) -> some View {
environment(\.imageDithering, dithering)
}
/// Sets the placeholder text shown while an image is loading.
///
/// - Parameter text: The placeholder text, or nil for no text.
/// - Returns: A modified view.
public func imagePlaceholder(_ text: String?) -> some View {
environment(\.imagePlaceholderText, text)
}
/// Controls whether a spinner is shown while an image is loading.
///
/// - Parameter showSpinner: Whether to show a spinner.
/// - Returns: A modified view.
public func imagePlaceholderSpinner(_ showSpinner: Bool) -> some View {
environment(\.imagePlaceholderSpinner, showSpinner)
}
/// Sets the aspect ratio and content mode for image rendering.
///
/// Use this modifier to control how images are scaled within their
/// available space.
///
/// ```swift
/// // Use natural aspect ratio, fit within bounds
/// Image(.file("photo.png"))
/// .aspectRatio(contentMode: .fit)
///
/// // Force 16:9 ratio, fill bounds
/// Image(.url("https://example.com/banner.png"))
/// .aspectRatio(16.0/9.0, contentMode: .fill)
/// ```
///
/// - Parameters:
/// - aspectRatio: The ratio of width to height to use for the
/// resulting view. Use `nil` to maintain the source image's
/// natural aspect ratio.
/// - contentMode: A flag that indicates whether this view fits or
/// fills the parent context.
/// - Returns: A view that constrains this view's dimensions to the
/// given aspect ratio and content mode.
public func aspectRatio(_ aspectRatio: Double? = nil, contentMode: ContentMode) -> some View {
environment(\.imageContentMode, contentMode)
.environment(\.imageAspectRatio, aspectRatio)
}
/// Scales this view to fit within the parent while maintaining the
/// aspect ratio.
///
/// Equivalent to `.aspectRatio(contentMode: .fit)`.
///
/// - Returns: A view that scales to fit.
public func scaledToFit() -> some View {
aspectRatio(contentMode: .fit)
}
/// Scales this view to fill the parent while maintaining the
/// aspect ratio.
///
/// Equivalent to `.aspectRatio(contentMode: .fill)`.
///
/// - Returns: A view that scales to fill.
public func scaledToFill() -> some View {
aspectRatio(contentMode: .fill)
}
}
+1
View File
@@ -276,6 +276,7 @@ private struct _SecureFieldCore: View, Renderable, Layoutable {
handler.text = text
handler.canBeFocused = !isDisabled
handler.onSubmit = onSubmitAction
handler.textContentType = context.environment.textContentType
handler.clampCursorPosition()
FocusRegistration.register(context: context, handler: handler)
+1
View File
@@ -285,6 +285,7 @@ private struct _TextFieldCore<Label: View>: View, Renderable, Layoutable {
handler.text = text
handler.canBeFocused = !isDisabled
handler.onSubmit = onSubmitAction
handler.textContentType = context.environment.textContentType
handler.clampCursorPosition()
FocusRegistration.register(context: context, handler: handler)
+258
View File
@@ -0,0 +1,258 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// _ImageCore.swift
//
// Created by LAYERED.work
// License: MIT
import Foundation
// MARK: - State Indices
/// Named property indices for `_ImageCore` state storage.
private enum StateIndex {
/// Stores the loading phase (`ImageLoadingPhase`).
static let phase = 0
/// Stores the last loaded source for change detection (`ImageSource`).
static let lastSource = 1
}
// MARK: - Image Core
/// Private rendering implementation for ``Image``.
///
/// Handles async image loading, caching, and placeholder display.
/// The raw `RGBAImage` is cached in state; ASCII conversion happens
/// on every render pass so that environment changes (character set,
/// color mode, dithering) take effect immediately.
struct _ImageCore: View, Renderable, Layoutable {
/// The image source.
let source: ImageSource
var body: Never {
fatalError("_ImageCore renders via Renderable")
}
// MARK: - Layoutable
func sizeThatFits(proposal: ProposedSize, context: RenderContext) -> ViewSize {
let w = proposal.width ?? context.availableWidth
let h = proposal.height ?? context.availableHeight
return .fixed(w, h)
}
// MARK: - Renderable
func renderToBuffer(context: RenderContext) -> FrameBuffer {
let stateStorage = context.tuiContext.stateStorage
let lifecycle = context.tuiContext.lifecycle
let identity = context.identity
let width = context.availableWidth
let height = context.availableHeight
guard width > 0, height > 0 else {
return FrameBuffer()
}
// Read environment values
let characterSet = context.environment.imageCharacterSet
let colorMode = context.environment.imageColorMode
let dithering = context.environment.imageDithering
let contentMode = context.environment.imageContentMode
let aspectRatioOverride = context.environment.imageAspectRatio
let placeholderText = context.environment.imagePlaceholderText
let showSpinner = context.environment.imagePlaceholderSpinner
// Retrieve or create persistent phase state
let phaseKey = StateStorage.StateKey(identity: identity, propertyIndex: StateIndex.phase)
let phaseBox: StateBox<ImageLoadingPhase> = stateStorage.storage(for: phaseKey, default: .loading)
stateStorage.markActive(identity)
// Track the last loaded source to detect changes
let sourceKey = StateStorage.StateKey(identity: identity, propertyIndex: StateIndex.lastSource)
let lastSourceBox: StateBox<ImageSource?> = stateStorage.storage(for: sourceKey, default: nil)
// Build a unique token for this image source
let token = "image-\(identity.path)"
// Detect source change and force reload
if let lastSource = lastSourceBox.value, lastSource != source {
lifecycle.cancelTask(token: token)
lifecycle.resetAppearance(token: token)
phaseBox.value = .loading
}
lastSourceBox.value = source
// Start loading on first appearance
if !lifecycle.hasAppeared(token: token) {
_ = lifecycle.recordAppear(token: token) {}
let src = source
lifecycle.startTask(token: token, priority: .userInitiated) {
let loader = PlatformImageLoader()
do {
let rawImage: RGBAImage
switch src {
case .file(let path):
rawImage = try loader.loadImage(from: path)
case .url(let urlString):
rawImage = try loader.loadImage(from: urlString, cache: .shared)
}
// Store the raw image; conversion happens per render pass.
// StateBox.didSet triggers setNeedsRender() automatically.
// Do NOT use MainActor.run here: the render loop blocks the
// main actor with usleep, so MainActor.run would deadlock.
phaseBox.value = .success(rawImage)
} catch let loadError as ImageLoadError {
phaseBox.value = .failure(loadError.description)
} catch {
phaseBox.value = .failure(error.localizedDescription)
}
}
} else {
_ = lifecycle.recordAppear(token: token) {}
}
// Cancel loading task on disappear
lifecycle.registerDisappear(token: token) { [lifecycle] in
lifecycle.cancelTask(token: token)
}
// Render based on current phase
switch phaseBox.value {
case .loading:
return renderPlaceholder(
width: width,
height: height,
text: placeholderText,
showSpinner: showSpinner,
context: context
)
case .success(let rawImage):
return renderImage(
rawImage,
width: width,
height: height,
characterSet: characterSet,
colorMode: colorMode,
dithering: dithering,
contentMode: contentMode,
aspectRatioOverride: aspectRatioOverride
)
case .failure(let message):
return renderError(message, width: width, height: height, context: context)
}
}
}
// MARK: - Image Rendering
extension _ImageCore {
/// Converts the raw image to ASCII art for the current frame dimensions and settings.
private func renderImage(
_ rawImage: RGBAImage,
width: Int,
height: Int,
characterSet: ASCIICharacterSet,
colorMode: ASCIIColorMode,
dithering: DitheringMode,
contentMode: ContentMode,
aspectRatioOverride: Double?
) -> FrameBuffer {
let targetSize = ASCIIConverter.targetSize(
imageWidth: rawImage.width,
imageHeight: rawImage.height,
maxWidth: width,
maxHeight: height,
contentMode: contentMode,
overrideAspectRatio: aspectRatioOverride
)
guard targetSize.width > 0, targetSize.height > 0 else {
return FrameBuffer()
}
let converter = ASCIIConverter(
characterSet: characterSet,
colorMode: colorMode,
dithering: dithering
)
let lines = converter.convert(rawImage, width: targetSize.width, height: targetSize.height)
return FrameBuffer(lines: lines)
}
}
// MARK: - Placeholder Rendering
extension _ImageCore {
/// Renders a centered placeholder with optional spinner and text.
private func renderPlaceholder(
width: Int,
height: Int,
text: String?,
showSpinner: Bool,
context: RenderContext
) -> FrameBuffer {
let palette = context.environment.palette
// Build placeholder content lines
var contentLines: [String] = []
if showSpinner {
let spinnerText = ""
let colored = ANSIRenderer.colorize(spinnerText, foreground: palette.accent)
contentLines.append(colored)
}
if let text {
let colored = ANSIRenderer.colorize(text, foreground: palette.foregroundSecondary)
contentLines.append(colored)
}
if contentLines.isEmpty {
contentLines.append(ANSIRenderer.colorize("Loading...", foreground: palette.foregroundSecondary))
}
return centerContent(contentLines, width: width, height: height)
}
/// Renders an error message centered in the frame.
private func renderError(
_ message: String,
width: Int,
height: Int,
context: RenderContext
) -> FrameBuffer {
let palette = context.environment.palette
let errorText = ANSIRenderer.colorize("Error: \(message)", foreground: palette.error)
return centerContent([errorText], width: width, height: height)
}
/// Centers content lines vertically and horizontally within the given dimensions.
private func centerContent(_ contentLines: [String], width: Int, height: Int) -> FrameBuffer {
let emptyLine = String(repeating: " ", count: width)
var lines = [String](repeating: emptyLine, count: height)
let startY = max(0, (height - contentLines.count) / 2)
for (i, content) in contentLines.enumerated() {
let y = startY + i
guard y < height else { break }
// Calculate visible width of content (excluding ANSI codes)
let visibleWidth = content.filter { !$0.isASCII || ($0.asciiValue ?? 0) >= 32 }.count
let padding = max(0, (width - visibleWidth) / 2)
let padded = String(repeating: " ", count: padding) + content
lines[y] = padded
}
return FrameBuffer(lines: lines, width: width)
}
}
+31 -37
View File
@@ -27,6 +27,8 @@ enum DemoPage: Int, CaseIterable {
case sliders
case steppers
case splitView
case imageFile
case imageURL
}
// MARK: - Content View (Page Router)
@@ -56,44 +58,12 @@ struct ContentView: View {
return true // Consumed
}
return false // Let default handler exit the app
case .character("8"):
// Quick jump to Text Fields
currentPage = .textFields
return true
case .character("\\"):
// Quick jump to Secure Fields
currentPage = .secureFields
return true
case .character("9"):
// Quick jump to Radio Buttons
currentPage = .radioButtons
return true
case .character("0"):
// Quick jump to Spinners
currentPage = .spinners
return true
case .character("-"):
// Quick jump to Lists
currentPage = .lists
return true
case .character("="):
// Quick jump to Tables
currentPage = .tables
return true
case .character("["):
// Quick jump to Sliders
currentPage = .sliders
return true
case .character("]"):
// Quick jump to Steppers
currentPage = .steppers
return true
case .character(";"):
// Quick jump to Split View
currentPage = .splitView
return true
default:
return false // Let other handlers process
// Quick-jump shortcuts only work from the menu page.
// On sub-pages they would conflict with text input
// (e.g. TextField, SecureField).
guard currentPage == .menu else { return false }
return handleMenuShortcut(event.key)
}
}
}
@@ -155,6 +125,10 @@ struct ContentView: View {
case .splitView:
SplitViewPage()
.statusBarItems(subPageItems(pageSetter: pageSetter))
case .imageFile:
ImageFilePage()
case .imageURL:
ImageURLPage()
}
}
@@ -167,4 +141,24 @@ struct ContentView: View {
StatusBarItem(shortcut: Shortcut.arrowsUpDown, label: "scroll"),
]
}
/// Handles quick-jump shortcuts from the menu page.
///
/// - Returns: `true` if the key was consumed, `false` otherwise.
private func handleMenuShortcut(_ key: Key) -> Bool {
let mapping: [Character: DemoPage] = [
"1": .textStyles, "2": .colors, "3": .containers,
"4": .overlays, "5": .layout, "6": .buttons,
"7": .toggles, "8": .textFields, "\\": .secureFields,
"9": .radioButtons, "0": .spinners, "-": .lists,
"=": .tables, "[": .sliders, "]": .steppers,
";": .splitView, "'": .imageFile, ",": .imageURL,
]
if case .character(let ch) = key, let page = mapping[ch] {
currentPage = page
return true
}
return false
}
}
@@ -0,0 +1,93 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// ImageFilePage.swift
//
// Created by LAYERED.work
// License: MIT
import Foundation
import TUIkit
/// Image demo page for loading an image from the local filesystem.
///
/// Displays a bundled demo image and provides status bar items to
/// cycle through character set, color mode, and dithering settings.
struct ImageFilePage: View {
@State var charSetIndex: Int = 0
@State var colorModeIndex: Int = 0
@State var ditheringOn: Bool = false
var body: some View {
let charSet = Self.charSets[charSetIndex]
let colorMode = Self.colorModes[colorModeIndex]
let dithering: DitheringMode = ditheringOn ? .floydSteinberg : .none
VStack(alignment: .leading) {
HStack {
Spacer()
if let path = Bundle.module.path(forResource: "demo-image", ofType: "jpg", inDirectory: "Resources") {
Image(.file(path))
.imagePlaceholder("Loading image...")
.imagePlaceholderSpinner(true)
} else {
Text("Resource not found: demo-image.jpg")
.foregroundStyle(.error)
}
Spacer()
}
.padding(.bottom, 1)
Spacer()
}
.imageCharacterSet(charSet)
.imageColorMode(colorMode)
.imageDithering(dithering)
.statusBarItems(statusBarItems)
.appHeader {
HStack {
Text("Image (File)").bold().foregroundStyle(.palette.accent)
Spacer()
Text("TUIkit v\(tuiKitVersion)").foregroundStyle(.palette.foregroundTertiary)
}
}
}
private var statusBarItems: [any StatusBarItemProtocol] {
[
StatusBarItem(shortcut: Shortcut.escape, label: "back"),
StatusBarItem(shortcut: "c", label: Self.charSetLabel(charSetIndex)) {
charSetIndex = (charSetIndex + 1) % Self.charSets.count
},
StatusBarItem(shortcut: "m", label: Self.colorModeLabel(colorModeIndex)) {
colorModeIndex = (colorModeIndex + 1) % Self.colorModes.count
},
StatusBarItem(shortcut: "d", label: ditheringOn ? "dither:on" : "dither:off") {
ditheringOn.toggle()
},
StatusBarItem(shortcut: Shortcut.arrowsUpDown, label: "scroll"),
]
}
}
// MARK: - Modifier Options
extension ImageFilePage {
static let charSets: [ASCIICharacterSet] = [.blocks, .ascii, .braille]
static let colorModes: [ASCIIColorMode] = [.trueColor, .ansi256, .grayscale, .mono]
static func charSetLabel(_ index: Int) -> String {
switch charSets[index] {
case .ascii: return "chars:ascii"
case .blocks: return "chars:blocks"
case .braille: return "chars:braille"
}
}
static func colorModeLabel(_ index: Int) -> String {
switch colorModes[index] {
case .trueColor: return "color:true"
case .ansi256: return "color:256"
case .grayscale: return "color:gray"
case .mono: return "color:mono"
}
}
}
@@ -0,0 +1,114 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// ImageURLPage.swift
//
// Created by LAYERED.work
// License: MIT
import Foundation
import TUIkit
/// Image demo page for loading an image from a URL.
///
/// Provides a text field for entering an image URL. After pressing
/// Enter the image is downloaded and rendered. Status bar items allow
/// cycling through character set, color mode, and dithering settings.
struct ImageURLPage: View {
@State var imageURL: String = ""
@State var activeURL: String = ""
@State var charSetIndex: Int = 0
@State var colorModeIndex: Int = 0
@State var ditheringOn: Bool = false
var body: some View {
let charSet = Self.charSets[charSetIndex]
let colorMode = Self.colorModes[colorModeIndex]
let dithering: DitheringMode = ditheringOn ? .floydSteinberg : .none
VStack(alignment: .leading) {
HStack(spacing: 1) {
Text("URL:")
.foregroundStyle(.palette.foregroundSecondary)
TextField("Enter image URL...", text: $imageURL)
.onSubmit {
activeURL = imageURL
}
.textContentType(.url)
}
.padding(.bottom, 1)
if !activeURL.isEmpty {
HStack {
Spacer()
Image(.url(activeURL))
.imagePlaceholder("Downloading...")
.imagePlaceholderSpinner(true)
.border(color: .palette.border)
Spacer()
}
} else {
HStack {
Spacer()
Text("Press Enter to load the image")
.foregroundStyle(.palette.foregroundTertiary)
.italic()
Spacer()
}
Spacer()
}
Spacer()
}
.imageCharacterSet(charSet)
.imageColorMode(colorMode)
.imageDithering(dithering)
.statusBarItems(statusBarItems)
.appHeader {
HStack {
Text("Image (URL)").bold().foregroundStyle(.palette.accent)
Spacer()
Text("TUIkit v\(tuiKitVersion)").foregroundStyle(.palette.foregroundTertiary)
}
}
}
private var statusBarItems: [any StatusBarItemProtocol] {
[
StatusBarItem(shortcut: Shortcut.escape, label: "back"),
StatusBarItem(shortcut: "c", label: Self.charSetLabel(charSetIndex)) {
charSetIndex = (charSetIndex + 1) % Self.charSets.count
},
StatusBarItem(shortcut: "m", label: Self.colorModeLabel(colorModeIndex)) {
colorModeIndex = (colorModeIndex + 1) % Self.colorModes.count
},
StatusBarItem(shortcut: "d", label: ditheringOn ? "dither:on" : "dither:off") {
ditheringOn.toggle()
},
StatusBarItem(shortcut: Shortcut.arrowsUpDown, label: "scroll"),
]
}
}
// MARK: - Modifier Options
extension ImageURLPage {
static let charSets: [ASCIICharacterSet] = [.blocks, .ascii, .braille]
static let colorModes: [ASCIIColorMode] = [.trueColor, .ansi256, .grayscale, .mono]
static func charSetLabel(_ index: Int) -> String {
switch charSets[index] {
case .ascii: return "chars:ascii"
case .blocks: return "chars:blocks"
case .braille: return "chars:braille"
}
}
static func colorModeLabel(_ index: Int) -> String {
switch colorModes[index] {
case .trueColor: return "color:true"
case .ansi256: return "color:256"
case .grayscale: return "color:gray"
case .mono: return "color:mono"
}
}
}
@@ -69,6 +69,8 @@ struct MainMenuPage: View {
MenuItem(label: "Sliders", shortcut: "["),
MenuItem(label: "Steppers", shortcut: "]"),
MenuItem(label: "Split View", shortcut: ";"),
MenuItem(label: "Image (File)", shortcut: "'"),
MenuItem(label: "Image (URL)", shortcut: ","),
],
selection: $menuSelection,
onSelect: { index in
Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

+337
View File
@@ -0,0 +1,337 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// ImageTests.swift
//
// Created by LAYERED.work
// License: MIT
import Testing
@testable import TUIkit
// MARK: - RGBA Tests
@Suite("RGBA Pixel Tests")
struct RGBAPixelTests {
@Test("RGBA initializes with correct values")
func rgbaInit() {
let pixel = RGBA(r: 255, g: 128, b: 0, a: 200)
#expect(pixel.r == 255)
#expect(pixel.g == 128)
#expect(pixel.b == 0)
#expect(pixel.a == 200)
}
@Test("RGBA default alpha is 255 (opaque)")
func rgbaDefaultAlpha() {
let pixel = RGBA(r: 100, g: 100, b: 100)
#expect(pixel.a == 255)
}
@Test("Luminance calculation follows ITU-R BT.601")
func luminanceCalculation() {
// Pure white
let white = RGBA(r: 255, g: 255, b: 255)
#expect(white.luminance > 254.0)
// Pure black
let black = RGBA(r: 0, g: 0, b: 0)
#expect(black.luminance == 0.0)
// Green contributes most to luminance
let green = RGBA(r: 0, g: 255, b: 0)
let red = RGBA(r: 255, g: 0, b: 0)
#expect(green.luminance > red.luminance)
}
@Test("RGBA equality works correctly")
func rgbaEquality() {
let a = RGBA(r: 10, g: 20, b: 30, a: 40)
let b = RGBA(r: 10, g: 20, b: 30, a: 40)
let c = RGBA(r: 10, g: 20, b: 31, a: 40)
#expect(a == b)
#expect(a != c)
}
}
// MARK: - RGBAImage Tests
@Suite("RGBAImage Tests")
struct RGBAImageTests {
@Test("Image stores correct dimensions")
func imageDimensions() {
let pixels = [RGBA](repeating: RGBA(r: 0, g: 0, b: 0), count: 12)
let image = RGBAImage(width: 4, height: 3, pixels: pixels)
#expect(image.width == 4)
#expect(image.height == 3)
}
@Test("Pixel access returns correct values")
func pixelAccess() {
var pixels = [RGBA](repeating: RGBA(r: 0, g: 0, b: 0), count: 4)
pixels[3] = RGBA(r: 255, g: 0, b: 0) // (1, 1) in a 2x2 image
let image = RGBAImage(width: 2, height: 2, pixels: pixels)
let topLeft = image.pixel(at: 0, 0)
#expect(topLeft.r == 0)
let bottomRight = image.pixel(at: 1, 1)
#expect(bottomRight.r == 255)
}
@Test("Set pixel modifies correct position")
func setPixel() {
let pixels = [RGBA](repeating: RGBA(r: 0, g: 0, b: 0), count: 4)
var image = RGBAImage(width: 2, height: 2, pixels: pixels)
image.setPixel(at: 1, 0, value: RGBA(r: 128, g: 64, b: 32))
let pixel = image.pixel(at: 1, 0)
#expect(pixel.r == 128)
#expect(pixel.g == 64)
#expect(pixel.b == 32)
}
@Test("Add error clamps to valid range")
func addErrorClamping() {
let pixels = [RGBA(r: 250, g: 5, b: 128)]
var image = RGBAImage(width: 1, height: 1, pixels: pixels)
// Adding positive error should clamp at 255
image.addError(at: 0, 0, rError: 20.0, gError: -10.0, bError: 0.0)
let pixel = image.pixel(at: 0, 0)
#expect(pixel.r == 255) // 250 + 20 -> clamped to 255
#expect(pixel.g == 0) // 5 - 10 -> clamped to 0
#expect(pixel.b == 128) // unchanged
}
@Test("Nearest-neighbor scaling produces correct dimensions")
func nearestNeighborScaling() {
let pixels = [RGBA](repeating: RGBA(r: 128, g: 128, b: 128), count: 100)
let image = RGBAImage(width: 10, height: 10, pixels: pixels)
let scaled = image.scaled(to: 5, 5)
#expect(scaled.width == 5)
#expect(scaled.height == 5)
}
@Test("Bilinear scaling produces correct dimensions")
func bilinearScaling() {
let pixels = [RGBA](repeating: RGBA(r: 128, g: 128, b: 128), count: 100)
let image = RGBAImage(width: 10, height: 10, pixels: pixels)
let scaled = image.scaledBilinear(to: 20, 20)
#expect(scaled.width == 20)
#expect(scaled.height == 20)
}
@Test("Scaling to zero returns empty image")
func scalingToZero() {
let pixels = [RGBA](repeating: RGBA(r: 0, g: 0, b: 0), count: 4)
let image = RGBAImage(width: 2, height: 2, pixels: pixels)
let scaled = image.scaled(to: 0, 0)
#expect(scaled.width == 0)
#expect(scaled.height == 0)
}
}
// MARK: - ASCIIConverter Tests
@Suite("ASCIIConverter Tests")
struct ASCIIConverterTests {
@Test("Target size calculation preserves aspect ratio")
func targetSizeAspectRatio() {
let size = ASCIIConverter.targetSize(
imageWidth: 100,
imageHeight: 100,
maxWidth: 50
)
// 100x100 image -> 50 chars wide, ~25 chars tall (2:1 aspect correction)
#expect(size.width == 50)
#expect(size.height == 25)
}
@Test("Target size respects max height")
func targetSizeMaxHeight() {
let size = ASCIIConverter.targetSize(
imageWidth: 100,
imageHeight: 200,
maxWidth: 80,
maxHeight: 20
)
#expect(size.height <= 20)
#expect(size.width > 0)
}
@Test("Target size is at least 1x1")
func targetSizeMinimum() {
let size = ASCIIConverter.targetSize(
imageWidth: 1,
imageHeight: 1,
maxWidth: 1
)
#expect(size.width >= 1)
#expect(size.height >= 1)
}
@Test("ASCII character set conversion produces output")
func asciiConversion() {
let pixels = [RGBA](repeating: RGBA(r: 128, g: 128, b: 128), count: 100)
let image = RGBAImage(width: 10, height: 10, pixels: pixels)
let converter = ASCIIConverter(characterSet: .ascii, colorMode: .mono, dithering: .none)
let lines = converter.convert(image, width: 10, height: 5)
#expect(lines.count == 5)
#expect(!lines[0].isEmpty)
}
@Test("Block character set conversion produces output")
func blockConversion() {
let pixels = [RGBA](repeating: RGBA(r: 200, g: 100, b: 50), count: 100)
let image = RGBAImage(width: 10, height: 10, pixels: pixels)
let converter = ASCIIConverter(characterSet: .blocks, colorMode: .trueColor, dithering: .none)
let lines = converter.convert(image, width: 10, height: 5)
#expect(lines.count == 5)
}
@Test("Braille conversion produces output")
func brailleConversion() {
let pixels = [RGBA](repeating: RGBA(r: 255, g: 255, b: 255), count: 400)
let image = RGBAImage(width: 20, height: 20, pixels: pixels)
let converter = ASCIIConverter(characterSet: .braille, colorMode: .trueColor, dithering: .none)
let lines = converter.convert(image, width: 10, height: 5)
#expect(lines.count == 5)
}
@Test("True color output contains ANSI RGB codes")
func trueColorOutput() {
let pixels = [RGBA(r: 255, g: 0, b: 0)]
let image = RGBAImage(width: 1, height: 1, pixels: pixels)
let converter = ASCIIConverter(characterSet: .ascii, colorMode: .trueColor, dithering: .none)
let lines = converter.convert(image, width: 1, height: 1)
#expect(lines.count == 1)
// Should contain 38;2; (foreground true color escape)
#expect(lines[0].contains("38;2;"))
}
@Test("Mono output contains no ANSI codes")
func monoOutput() {
let pixels = [RGBA(r: 128, g: 128, b: 128)]
let image = RGBAImage(width: 1, height: 1, pixels: pixels)
let converter = ASCIIConverter(characterSet: .ascii, colorMode: .mono, dithering: .none)
let lines = converter.convert(image, width: 1, height: 1)
#expect(lines.count == 1)
// Mono should not contain color escape sequences
#expect(!lines[0].contains("38;2;"))
#expect(!lines[0].contains("38;5;"))
}
@Test("Floyd-Steinberg dithering does not crash")
func ditheringNoCrash() {
var pixels = [RGBA]()
for i in 0..<100 {
let r = UInt8(clamping: i * 2)
let g = UInt8(clamping: i)
let b = UInt8(clamping: 255 - i * 2)
pixels.append(RGBA(r: r, g: g, b: b))
}
let image = RGBAImage(width: 10, height: 10, pixels: pixels)
let converter = ASCIIConverter(characterSet: .blocks, colorMode: .ansi256, dithering: .floydSteinberg)
let lines = converter.convert(image, width: 10, height: 5)
#expect(lines.count == 5)
}
@Test("Empty image returns empty lines")
func emptyImageConversion() {
let image = RGBAImage(width: 0, height: 0, pixels: [])
let converter = ASCIIConverter()
let lines = converter.convert(image, width: 10, height: 5)
#expect(lines.isEmpty)
}
@Test("ANSI 256 output contains palette codes")
func ansi256Output() {
let pixels = [RGBA(r: 255, g: 0, b: 0)]
let image = RGBAImage(width: 1, height: 1, pixels: pixels)
let converter = ASCIIConverter(characterSet: .ascii, colorMode: .ansi256, dithering: .none)
let lines = converter.convert(image, width: 1, height: 1)
#expect(lines.count == 1)
// Should contain 38;5; (256-color escape)
#expect(lines[0].contains("38;5;"))
}
@Test("Grayscale output contains palette codes")
func grayscaleOutput() {
let pixels = [RGBA(r: 128, g: 128, b: 128)]
let image = RGBAImage(width: 1, height: 1, pixels: pixels)
let converter = ASCIIConverter(characterSet: .ascii, colorMode: .grayscale, dithering: .none)
let lines = converter.convert(image, width: 1, height: 1)
#expect(lines.count == 1)
#expect(lines[0].contains("38;5;"))
}
}
// MARK: - Image View Tests
@Suite("Image View Tests")
@MainActor
struct ImageViewTests {
@Test("Image initializes with file source")
func imageFileInit() {
let image = Image(.file("/path/to/image.png"))
#expect(image.source == .file("/path/to/image.png"))
}
@Test("Image initializes with URL source")
func imageURLInit() {
let image = Image(.url("https://example.com/image.png"))
#expect(image.source == .url("https://example.com/image.png"))
}
@Test("ImageSource equality works")
func imageSourceEquality() {
let a = ImageSource.file("/path/a.png")
let b = ImageSource.file("/path/a.png")
let c = ImageSource.url("https://example.com")
#expect(a == b)
#expect(a != c)
}
}
// MARK: - ImageLoadError Tests
@Suite("ImageLoadError Tests")
struct ImageLoadErrorTests {
@Test("Error descriptions are informative")
func errorDescriptions() {
let fileError = ImageLoadError.fileNotFound("/missing.png")
#expect(fileError.description.contains("/missing.png"))
let formatError = ImageLoadError.unsupportedFormat("bmp")
#expect(formatError.description.contains("bmp"))
let decodeError = ImageLoadError.decodingFailed("corrupt data")
#expect(decodeError.description.contains("corrupt data"))
let downloadError = ImageLoadError.downloadFailed("timeout")
#expect(downloadError.description.contains("timeout"))
}
}