mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
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:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "20a49b01b0e08a4eba6bdf90e830aa1c43301d8c17142c3e986cd34a9ed67d16",
|
||||
"originHash" : "978155e2813f61182dd53d12465d831a26121bd7f15b38bea29cc7abc323457b",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "swift-docc-plugin",
|
||||
|
||||
+10
-2
@@ -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
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user