Refactor: Extract TUIkitStyling and TUIkitImage modules

- Extract 12 pure type definitions (Color, Palette, Appearance, BorderStyle, etc.) into TUIkitStyling module with no dependencies
- Extract 3 image processing files (RGBAImage, ImageLoader, ASCIIConverter) into TUIkitImage module (deps: CSTBImage, TUIkitStyling)
- Split 6 mixed files into type definitions (TUIkitStyling) and environment glue (TUIkit/Styling/)
- Decouple ThemeManager from AppState via renderTrigger closure
- Decouple ASCIIConverter from ANSIRenderer via local ANSIEscape constants
- Add @_exported imports in Exports.swift for backward compatibility
- All 1064 tests pass, no breaking API changes
This commit is contained in:
phranck
2026-02-14 03:14:14 +01:00
parent be19689b84
commit ce850e1b29
24 changed files with 436 additions and 358 deletions
+16 -1
View File
@@ -17,6 +17,14 @@ let package = Package(
name: "TUIkit",
targets: ["TUIkit"]
),
.library(
name: "TUIkitStyling",
targets: ["TUIkitStyling"]
),
.library(
name: "TUIkitImage",
targets: ["TUIkitImage"]
),
.executable(
name: "TUIkitExample",
targets: ["TUIkitExample"]
@@ -30,9 +38,16 @@ let package = Package(
name: "CSTBImage",
publicHeadersPath: "include"
),
.target(
name: "TUIkitStyling"
),
.target(
name: "TUIkitImage",
dependencies: ["CSTBImage", "TUIkitStyling"]
),
.target(
name: "TUIkit",
dependencies: ["CSTBImage"]
dependencies: ["TUIkitStyling", "TUIkitImage"]
),
.executableTarget(
name: "TUIkitExample",
+2 -2
View File
@@ -87,8 +87,8 @@ internal final class AppRunner<A: App> {
self.appHeader = AppHeaderState()
self.focusManager = FocusManager()
self.tuiContext = TUIContext()
self.paletteManager = ThemeManager(items: PaletteRegistry.all, appState: appState)
self.appearanceManager = ThemeManager(items: AppearanceRegistry.all, appState: appState)
self.paletteManager = ThemeManager(items: PaletteRegistry.all, renderTrigger: { [appState] in appState.setNeedsRender() })
self.appearanceManager = ThemeManager(items: AppearanceRegistry.all, renderTrigger: { [appState] in appState.setNeedsRender() })
// Configure status bar style
self.statusBar.style = .bordered
+9
View File
@@ -0,0 +1,9 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// Exports.swift
//
// Created by LAYERED.work
// License: MIT
// Re-export sub-modules so `import TUIkit` provides full API access.
@_exported import TUIkitStyling
@_exported import TUIkitImage
@@ -0,0 +1,59 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// AppearanceEnvironment.swift
//
// Created by LAYERED.work
// License: MIT
import TUIkitStyling
// MARK: - Appearance Environment Key
/// Environment key for the current appearance.
private struct AppearanceKey: EnvironmentKey {
static let defaultValue: Appearance = .default
}
extension EnvironmentValues {
/// The current appearance.
///
/// Set an appearance at the app level and it propagates to all child views:
///
/// ```swift
/// WindowGroup {
/// ContentView()
/// }
/// .appearance(.rounded)
/// ```
///
/// Access the appearance in `renderToBuffer(context:)`:
///
/// ```swift
/// let appearance = context.environment.appearance
/// let borderStyle = appearance.borderStyle
/// ```
public var appearance: Appearance {
get { self[AppearanceKey.self] }
set { self[AppearanceKey.self] = newValue }
}
}
// MARK: - AppearanceManager Environment Key
/// Environment key for the appearance manager.
private struct AppearanceManagerKey: EnvironmentKey {
static let defaultValue = ThemeManager(items: AppearanceRegistry.all)
}
extension EnvironmentValues {
/// The appearance manager for cycling and setting appearances.
///
/// ```swift
/// let appearanceManager = context.environment.appearanceManager
/// appearanceManager.cycleNext()
/// appearanceManager.setCurrent(Appearance.rounded)
/// ```
public var appearanceManager: ThemeManager {
get { self[AppearanceManagerKey.self] }
set { self[AppearanceManagerKey.self] = newValue }
}
}
@@ -0,0 +1,55 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// ColorEnvironment.swift
//
// Created by LAYERED.work
// License: MIT
import TUIkitStyling
// MARK: - Foreground Style Environment
/// Environment key for the foreground style.
///
/// When set via `.foregroundStyle(_:)` on any View, this value propagates
/// down through the view hierarchy. Child views can read it from the
/// render context to apply the color.
private struct ForegroundStyleKey: EnvironmentKey {
static let defaultValue: Color? = nil
}
extension EnvironmentValues {
/// The foreground style (color) for text and other content.
///
/// Set via `.foregroundStyle(_:)` modifier on any View.
/// Returns `nil` if not explicitly set (use palette default).
public var foregroundStyle: Color? {
get { self[ForegroundStyleKey.self] }
set { self[ForegroundStyleKey.self] = newValue }
}
}
// MARK: - View Extension for foregroundStyle
extension View {
/// Sets the foreground style for this view and its children.
///
/// The style propagates through the view hierarchy via the environment.
/// Child views that render text or other colored content should read
/// `context.environment.foregroundStyle` and apply it.
///
/// ## Example
///
/// ```swift
/// VStack {
/// Text("Red text")
/// Text("Also red")
/// }
/// .foregroundStyle(.red)
/// ```
///
/// - Parameter style: The color to apply as foreground style.
/// - Returns: A view with the foreground style set.
public func foregroundStyle(_ style: Color?) -> some View {
environment(\.foregroundStyle, style)
}
}
@@ -0,0 +1,59 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// PaletteEnvironment.swift
//
// Created by LAYERED.work
// License: MIT
import TUIkitStyling
// MARK: - Palette Environment Key
/// Environment key for the current palette.
private struct PaletteKey: EnvironmentKey {
static let defaultValue: any Palette = SystemPalette(.green)
}
extension EnvironmentValues {
/// The current palette.
///
/// Set a palette at the app level and it propagates to all child views:
///
/// ```swift
/// WindowGroup {
/// ContentView()
/// }
/// .environment(\.palette, SystemPalette(.green))
/// ```
///
/// Access the palette in `renderToBuffer(context:)`:
///
/// ```swift
/// let palette = context.environment.palette
/// let fg = palette.foreground
/// ```
public var palette: any Palette {
get { self[PaletteKey.self] }
set { self[PaletteKey.self] = newValue }
}
}
// MARK: - PaletteManager Environment Key
/// Environment key for the palette manager.
private struct PaletteManagerKey: EnvironmentKey {
static let defaultValue = ThemeManager(items: PaletteRegistry.all)
}
extension EnvironmentValues {
/// The palette manager for cycling and setting palettes.
///
/// ```swift
/// let paletteManager = context.environment.paletteManager
/// paletteManager.cycleNext()
/// paletteManager.setCurrent(SystemPalette(.amber))
/// ```
public var paletteManager: ThemeManager {
get { self[PaletteManagerKey.self] }
set { self[PaletteManagerKey.self] = newValue }
}
}
@@ -0,0 +1,53 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// TextContentTypeEnvironment.swift
//
// Created by LAYERED.work
// License: MIT
import TUIkitStyling
// 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)
}
}
@@ -0,0 +1,71 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// TextCursorStyleEnvironment.swift
//
// Created by LAYERED.work
// License: MIT
import TUIkitStyling
// MARK: - Environment Key
/// Environment key for the text cursor style.
private struct TextCursorStyleKey: EnvironmentKey {
static let defaultValue = TextCursorStyle()
}
extension EnvironmentValues {
/// The text cursor style for text fields.
///
/// Set this value using the `.textCursor(_:)` modifier:
///
/// ```swift
/// TextField("Name", text: $name)
/// .textCursor(.bar, animation: .blink)
/// ```
public var textCursorStyle: TextCursorStyle {
get { self[TextCursorStyleKey.self] }
set { self[TextCursorStyleKey.self] = newValue }
}
}
// MARK: - View Extension
extension View {
/// Sets the text cursor style for text fields within this view.
///
/// Use this modifier to customize the cursor appearance in ``TextField``
/// and ``SecureField`` components.
///
/// ```swift
/// TextField("Name", text: $name)
/// .textCursor(.bar)
/// ```
///
/// - Parameter style: The cursor style to use.
/// - Returns: A view with the cursor style applied.
public func textCursor(_ style: TextCursorStyle) -> some View {
environment(\.textCursorStyle, style)
}
/// Sets the text cursor style with separate shape, animation, and speed parameters.
///
/// Use this modifier when you want to specify shape, animation, and speed:
///
/// ```swift
/// TextField("Code", text: $code)
/// .textCursor(.underscore, animation: .blink, speed: .fast)
/// ```
///
/// - Parameters:
/// - shape: The cursor shape.
/// - animation: The cursor animation. Defaults to `.pulse`.
/// - speed: The animation speed. Defaults to `.regular`.
/// - Returns: A view with the cursor style applied.
public func textCursor(
_ shape: TextCursorStyle.Shape,
animation: TextCursorStyle.Animation = .pulse,
speed: TextCursorStyle.Speed = .regular
) -> some View {
environment(\.textCursorStyle, TextCursorStyle(shape: shape, animation: animation, speed: speed))
}
}
@@ -0,0 +1,15 @@
// 🖥 TUIKit Terminal UI Kit for Swift
// ViewConstants+EdgeInsets.swift
//
// Created by LAYERED.work
// License: MIT
// MARK: - EdgeInsets Defaults
extension EdgeInsets {
/// Default insets for containers (Card, Panel, etc.): 1 horizontal, 0 vertical.
static let containerDefault = EdgeInsets(horizontal: 1, vertical: 0)
/// Default insets for dialogs: 2 horizontal, 1 vertical.
static let dialogDefault = EdgeInsets(horizontal: 2, vertical: 1)
}
@@ -4,6 +4,18 @@
// Created by LAYERED.work
// License: MIT
import TUIkitStyling
/// Standard ANSI escape sequences for ASCII art colorization.
private enum ANSIEscape {
/// The escape character.
static let escape = "\u{1B}"
/// The Control Sequence Introducer.
static let csi = "\(escape)["
/// Reset all formatting.
static let reset = "\(csi)0m"
}
// MARK: - Character Set
/// The set of characters used for ASCII art rendering.
@@ -58,7 +70,7 @@ public enum DitheringMode: Sendable, Equatable {
/// 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 {
public struct ASCIIConverter: Sendable {
/// The character set to use for brightness mapping.
let characterSet: ASCIICharacterSet
@@ -70,7 +82,7 @@ struct ASCIIConverter: Sendable {
let dithering: DitheringMode
/// Creates a converter with the specified options.
init(
public init(
characterSet: ASCIICharacterSet = .blocks,
colorMode: ASCIIColorMode = .trueColor,
dithering: DitheringMode = .none
@@ -92,7 +104,7 @@ extension ASCIIConverter {
/// - 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] {
public func convert(_ image: RGBAImage, width: Int, height: Int) -> [String] {
guard image.width > 0, image.height > 0, width > 0, height > 0 else {
return []
}
@@ -153,7 +165,7 @@ extension ASCIIConverter {
let colorCode = foregroundColorCode(for: pixel)
if colorCode != lastColor {
if !lastColor.isEmpty {
line += ANSIRenderer.reset
line += ANSIEscape.reset
}
line += colorCode
lastColor = colorCode
@@ -162,7 +174,7 @@ extension ASCIIConverter {
}
if !lastColor.isEmpty {
line += ANSIRenderer.reset
line += ANSIEscape.reset
}
lines.append(line)
}
@@ -259,7 +271,7 @@ extension ASCIIConverter {
let colorCode = foregroundColorCode(for: avgPixel)
if colorCode != lastColor {
if !lastColor.isEmpty {
line += ANSIRenderer.reset
line += ANSIEscape.reset
}
line += colorCode
lastColor = colorCode
@@ -268,7 +280,7 @@ extension ASCIIConverter {
}
if !lastColor.isEmpty {
line += ANSIRenderer.reset
line += ANSIEscape.reset
}
lines.append(line)
}
@@ -285,16 +297,16 @@ extension ASCIIConverter {
private func foregroundColorCode(for pixel: RGBA) -> String {
switch colorMode {
case .trueColor:
return "\(ANSIRenderer.csi)38;2;\(pixel.r);\(pixel.g);\(pixel.b)m"
return "\(ANSIEscape.csi)38;2;\(pixel.r);\(pixel.g);\(pixel.b)m"
case .ansi256:
let index = quantizeToANSI256(pixel)
return "\(ANSIRenderer.csi)38;5;\(index)m"
return "\(ANSIEscape.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"
return "\(ANSIEscape.csi)38;5;\(index)m"
case .mono:
return ""
@@ -446,7 +458,7 @@ extension ASCIIConverter {
/// - 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(
public static func targetSize(
imageWidth: Int,
imageHeight: Int,
maxWidth: Int,
@@ -13,7 +13,7 @@ import Foundation
///
/// 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 {
public protocol ImageLoader: Sendable {
/// Loads an image from a file path.
///
/// - Parameter path: The absolute file path to the image.
@@ -32,7 +32,7 @@ protocol ImageLoader: Sendable {
// MARK: - ImageLoadError
/// Errors that can occur during image loading.
enum ImageLoadError: Error, LocalizedError, CustomStringConvertible {
public enum ImageLoadError: Error, LocalizedError, CustomStringConvertible {
/// The file was not found at the given path.
case fileNotFound(String)
@@ -48,7 +48,7 @@ enum ImageLoadError: Error, LocalizedError, CustomStringConvertible {
/// The image exceeds the maximum allowed pixel count.
case imageTooLarge(pixelCount: Int, limit: Int)
var description: String {
public var description: String {
switch self {
case .fileNotFound(let path):
return "Image file not found: \(path)"
@@ -63,7 +63,7 @@ enum ImageLoadError: Error, LocalizedError, CustomStringConvertible {
}
}
var errorDescription: String? { description }
public var errorDescription: String? { description }
}
// MARK: - Platform Image Loader
@@ -73,13 +73,15 @@ enum ImageLoadError: Error, LocalizedError, CustomStringConvertible {
/// 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 {
public struct PlatformImageLoader: ImageLoader {
func loadImage(from path: String) throws -> RGBAImage {
public init() {}
public func loadImage(from path: String) throws -> RGBAImage {
try loadImage(from: path, maxPixelCount: nil)
}
func loadImage(from data: Data) throws -> RGBAImage {
public func loadImage(from data: Data) throws -> RGBAImage {
try loadImage(from: data, maxPixelCount: nil)
}
@@ -90,7 +92,7 @@ struct PlatformImageLoader: ImageLoader {
/// - maxPixelCount: The maximum allowed total pixel count, or `nil` for no limit.
/// - Returns: The decoded image as `RGBAImage`.
/// - Throws: `ImageLoadError` if the file cannot be read, decoded, or exceeds the limit.
func loadImage(from path: String, maxPixelCount: Int?) throws -> RGBAImage {
public func loadImage(from path: String, maxPixelCount: Int?) throws -> RGBAImage {
guard FileManager.default.fileExists(atPath: path) else {
throw ImageLoadError.fileNotFound(path)
}
@@ -120,7 +122,7 @@ struct PlatformImageLoader: ImageLoader {
/// - maxPixelCount: The maximum allowed total pixel count, or `nil` for no limit.
/// - Returns: The decoded image as `RGBAImage`.
/// - Throws: `ImageLoadError` if the data cannot be decoded or exceeds the limit.
func loadImage(from data: Data, maxPixelCount: Int?) throws -> RGBAImage {
public func loadImage(from data: Data, maxPixelCount: Int?) throws -> RGBAImage {
var width: Int32 = 0
var height: Int32 = 0
var channels: Int32 = 0
@@ -185,9 +187,9 @@ extension PlatformImageLoader {
///
/// Cached entries persist for the lifetime of the application.
/// Thread-safe via an internal lock.
final class URLImageCache: @unchecked Sendable {
public final class URLImageCache: @unchecked Sendable {
/// Shared session cache.
static let shared = URLImageCache()
public static let shared = URLImageCache()
private var cache: [String: RGBAImage] = [:]
private let lock = NSLock()
@@ -195,14 +197,14 @@ final class URLImageCache: @unchecked Sendable {
private init() {}
/// Returns a cached image for the given URL string, or nil.
func get(_ urlString: String) -> RGBAImage? {
public 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) {
public func set(_ urlString: String, image: RGBAImage) {
lock.lock()
defer { lock.unlock() }
cache[urlString] = image
@@ -225,7 +227,7 @@ extension PlatformImageLoader {
/// - maxPixelCount: The maximum allowed total pixel count, or `nil` for no limit.
/// - Returns: The decoded image.
/// - Throws: `ImageLoadError` on network or decoding failure, or if image exceeds size limit.
func loadImage(
public func loadImage(
from urlString: String,
cache: URLImageCache = .shared,
timeout: TimeInterval = 30,
@@ -10,14 +10,14 @@
///
/// 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
public struct RGBA: Sendable, Equatable {
public var r: UInt8
public var g: UInt8
public var b: UInt8
public var a: UInt8
/// Creates an opaque pixel with the given RGB values.
init(r: UInt8, g: UInt8, b: UInt8, a: UInt8 = 255) {
public init(r: UInt8, g: UInt8, b: UInt8, a: UInt8 = 255) {
self.r = r
self.g = g
self.b = b
@@ -32,7 +32,7 @@ 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 {
public var luminance: Double {
Double(r) * 0.299 + Double(g) * 0.587 + Double(b) * 0.114
}
}
@@ -43,15 +43,15 @@ extension RGBA {
///
/// This is the platform-independent representation produced by
/// `ImageLoader` implementations and consumed by `ASCIIConverter`.
struct RGBAImage: Sendable {
public struct RGBAImage: Sendable {
/// Image width in pixels.
let width: Int
public let width: Int
/// Image height in pixels.
let height: Int
public let height: Int
/// Row-major pixel data (`width * height` elements).
private(set) var pixels: [RGBA]
public private(set) var pixels: [RGBA]
/// Creates an image from dimensions and pixel data.
///
@@ -59,7 +59,7 @@ struct RGBAImage: Sendable {
/// - 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]) {
public init(width: Int, height: Int, pixels: [RGBA]) {
precondition(pixels.count == width * height, "Pixel count must match width * height")
self.width = width
self.height = height
@@ -77,7 +77,7 @@ extension RGBAImage {
/// - 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 {
public func pixel(at x: Int, _ y: Int) -> RGBA {
pixels[y * width + x]
}
@@ -87,7 +87,7 @@ extension RGBAImage {
/// - x: Column (0-based).
/// - y: Row (0-based).
/// - value: The new pixel value.
mutating func setPixel(at x: Int, _ y: Int, value: RGBA) {
public mutating func setPixel(at x: Int, _ y: Int, value: RGBA) {
pixels[y * width + x] = value
}
@@ -101,7 +101,7 @@ extension RGBAImage {
/// - 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) {
public mutating func addError(at x: Int, _ y: Int, rError: Double, gError: Double, bError: Double) {
let index = y * width + x
let pixel = pixels[index]
pixels[index] = RGBA(
@@ -122,7 +122,7 @@ extension RGBAImage {
/// - targetWidth: The desired width.
/// - targetHeight: The desired height.
/// - Returns: A new image with the specified dimensions.
func scaled(to targetWidth: Int, _ targetHeight: Int) -> RGBAImage {
public func scaled(to targetWidth: Int, _ targetHeight: Int) -> RGBAImage {
guard targetWidth > 0, targetHeight > 0 else {
return RGBAImage(width: 0, height: 0, pixels: [])
}
@@ -146,7 +146,7 @@ extension RGBAImage {
/// - targetWidth: The desired width.
/// - targetHeight: The desired height.
/// - Returns: A new image with the specified dimensions.
func scaledBilinear(to targetWidth: Int, _ targetHeight: Int) -> RGBAImage {
public func scaledBilinear(to targetWidth: Int, _ targetHeight: Int) -> RGBAImage {
guard targetWidth > 0, targetHeight > 0 else {
return RGBAImage(width: 0, height: 0, pixels: [])
}
@@ -5,7 +5,7 @@
// License: MIT
/// The 8 standard ANSI colors.
enum ANSIColor: UInt8, Sendable {
public enum ANSIColor: UInt8, Sendable {
case black = 0
case red = 1
case green = 2
@@ -17,29 +17,29 @@ enum ANSIColor: UInt8, Sendable {
case `default` = 9
/// The ANSI code for foreground color (30-37, 39 for default).
var foregroundCode: UInt8 {
public var foregroundCode: UInt8 {
30 + rawValue
}
/// The ANSI code for background color (40-47, 49 for default).
var backgroundCode: UInt8 {
public var backgroundCode: UInt8 {
40 + rawValue
}
/// The ANSI code for bright foreground color (90-97).
var brightForegroundCode: UInt8 {
public var brightForegroundCode: UInt8 {
90 + rawValue
}
/// The ANSI code for bright background color (100-107).
var brightBackgroundCode: UInt8 {
public var brightBackgroundCode: UInt8 {
100 + rawValue
}
// MARK: - xterm Standard RGB Values
/// The standard RGB values for this ANSI color (xterm defaults).
var rgbValues: (red: UInt8, green: UInt8, blue: UInt8) {
public var rgbValues: (red: UInt8, green: UInt8, blue: UInt8) {
switch self {
case .black: return (0, 0, 0)
case .red: return (205, 0, 0)
@@ -54,7 +54,7 @@ enum ANSIColor: UInt8, Sendable {
}
/// The bright RGB values for this ANSI color (xterm defaults).
var brightRGBValues: (red: UInt8, green: UInt8, blue: UInt8) {
public var brightRGBValues: (red: UInt8, green: UInt8, blue: UInt8) {
switch self {
case .black: return (127, 127, 127)
case .red: return (255, 0, 0)
@@ -151,11 +151,11 @@ extension Appearance {
// MARK: - Appearance Registry
/// Registry of available appearances for cycling.
struct AppearanceRegistry {
public struct AppearanceRegistry {
/// All available appearances in cycling order.
///
/// Order: rounded (default) line doubleLine heavy
static let all: [Appearance] = [
public static let all: [Appearance] = [
.rounded,
.line,
.doubleLine,
@@ -166,59 +166,7 @@ struct AppearanceRegistry {
///
/// - Parameter id: The appearance ID to find.
/// - Returns: The appearance, or nil if not found.
static func appearance(withId id: Appearance.ID) -> Appearance? {
public static func appearance(withId id: Appearance.ID) -> Appearance? {
all.first { $0.rawId == id }
}
}
// MARK: - Appearance Environment Key
/// Environment key for the current appearance.
private struct AppearanceKey: EnvironmentKey {
static let defaultValue: Appearance = .default
}
extension EnvironmentValues {
/// The current appearance.
///
/// Set an appearance at the app level and it propagates to all child views:
///
/// ```swift
/// WindowGroup {
/// ContentView()
/// }
/// .appearance(.rounded)
/// ```
///
/// Access the appearance in `renderToBuffer(context:)`:
///
/// ```swift
/// let appearance = context.environment.appearance
/// let borderStyle = appearance.borderStyle
/// ```
public var appearance: Appearance {
get { self[AppearanceKey.self] }
set { self[AppearanceKey.self] = newValue }
}
}
// MARK: - AppearanceManager Environment Key
/// Environment key for the appearance manager.
private struct AppearanceManagerKey: EnvironmentKey {
static let defaultValue = ThemeManager(items: AppearanceRegistry.all)
}
extension EnvironmentValues {
/// The appearance manager for cycling and setting appearances.
///
/// ```swift
/// let appearanceManager = context.environment.appearanceManager
/// appearanceManager.cycleNext()
/// appearanceManager.setCurrent(Appearance.rounded)
/// ```
public var appearanceManager: ThemeManager {
get { self[AppearanceManagerKey.self] }
set { self[AppearanceManagerKey.self] = newValue }
}
}
@@ -24,10 +24,10 @@
/// ```
public struct Color: Sendable, Equatable {
/// The internal color value.
let value: ColorValue
public let value: ColorValue
/// Internal enum for different color types.
enum ColorValue: Sendable, Equatable {
public enum ColorValue: Sendable, Equatable {
case standard(ANSIColor)
case bright(ANSIColor)
case palette256(UInt8)
@@ -372,7 +372,7 @@ public extension Color {
// MARK: - Internal API
extension Color {
public extension Color {
/// Converts RGB components to HSL (hue 0360, saturation 0100, lightness 0100).
///
/// - Parameters:
@@ -483,51 +483,3 @@ private extension Color {
return .hsl(hue, saturation, min(100, max(0, newLightness)))
}
}
// MARK: - Foreground Style Environment
/// Environment key for the foreground style.
///
/// When set via `.foregroundStyle(_:)` on any View, this value propagates
/// down through the view hierarchy. Child views can read it from the
/// render context to apply the color.
private struct ForegroundStyleKey: EnvironmentKey {
static let defaultValue: Color? = nil
}
extension EnvironmentValues {
/// The foreground style (color) for text and other content.
///
/// Set via `.foregroundStyle(_:)` modifier on any View.
/// Returns `nil` if not explicitly set (use palette default).
public var foregroundStyle: Color? {
get { self[ForegroundStyleKey.self] }
set { self[ForegroundStyleKey.self] = newValue }
}
}
// MARK: - View Extension for foregroundStyle
extension View {
/// Sets the foreground style for this view and its children.
///
/// The style propagates through the view hierarchy via the environment.
/// Child views that render text or other colored content should read
/// `context.environment.foregroundStyle` and apply it.
///
/// ## Example
///
/// ```swift
/// VStack {
/// Text("Red text")
/// Text("Also red")
/// }
/// .foregroundStyle(.red)
/// ```
///
/// - Parameter style: The color to apply as foreground style.
/// - Returns: A view with the foreground style set.
public func foregroundStyle(_ style: Color?) -> some View {
environment(\.foregroundStyle, style)
}
}
@@ -16,7 +16,7 @@
/// ```swift
/// Text("Hello").foregroundStyle(.palette.accent)
/// ```
enum SemanticColor: String, Sendable, Equatable {
public enum SemanticColor: String, Sendable, Equatable {
// Background
case background
case statusBarBackground
@@ -75,7 +75,7 @@ public enum TextContentType: Sendable, Equatable {
// MARK: - Character Filtering
extension TextContentType {
public extension TextContentType {
/// The set of Unicode scalars allowed for this content type.
///
/// `.password` returns `nil` to indicate no filtering.
@@ -161,49 +161,3 @@ private extension TextContentType {
/// 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)
}
}
@@ -1,4 +1,4 @@
// TUIKit - Terminal UI Kit for Swift
// 🖥 TUIKit Terminal UI Kit for Swift
// TextCursorStyle.swift
//
// Created by LAYERED.work
@@ -173,67 +173,3 @@ extension TextCursorStyle {
/// An underscore cursor with blink animation at regular speed.
public static let underscore = TextCursorStyle(shape: .underscore, animation: .blink, speed: .regular)
}
// MARK: - Environment Key
/// Environment key for the text cursor style.
private struct TextCursorStyleKey: EnvironmentKey {
static let defaultValue = TextCursorStyle()
}
extension EnvironmentValues {
/// The text cursor style for text fields.
///
/// Set this value using the `.textCursor(_:)` modifier:
///
/// ```swift
/// TextField("Name", text: $name)
/// .textCursor(.bar, animation: .blink)
/// ```
public var textCursorStyle: TextCursorStyle {
get { self[TextCursorStyleKey.self] }
set { self[TextCursorStyleKey.self] = newValue }
}
}
// MARK: - View Extension
extension View {
/// Sets the text cursor style for text fields within this view.
///
/// Use this modifier to customize the cursor appearance in ``TextField``
/// and ``SecureField`` components.
///
/// ```swift
/// TextField("Name", text: $name)
/// .textCursor(.bar)
/// ```
///
/// - Parameter style: The cursor style to use.
/// - Returns: A view with the cursor style applied.
public func textCursor(_ style: TextCursorStyle) -> some View {
environment(\.textCursorStyle, style)
}
/// Sets the text cursor style with separate shape, animation, and speed parameters.
///
/// Use this modifier when you want to specify shape, animation, and speed:
///
/// ```swift
/// TextField("Code", text: $code)
/// .textCursor(.underscore, animation: .blink, speed: .fast)
/// ```
///
/// - Parameters:
/// - shape: The cursor shape.
/// - animation: The cursor animation. Defaults to `.pulse`.
/// - speed: The animation speed. Defaults to `.regular`.
/// - Returns: A view with the cursor style applied.
public func textCursor(
_ shape: TextCursorStyle.Shape,
animation: TextCursorStyle.Animation = .pulse,
speed: TextCursorStyle.Speed = .regular
) -> some View {
environment(\.textCursorStyle, TextCursorStyle(shape: shape, animation: animation, speed: speed))
}
}
@@ -109,74 +109,22 @@ extension Palette {
public var cursorColor: Color { accent }
}
// MARK: - Palette Environment Key
/// Environment key for the current palette.
private struct PaletteKey: EnvironmentKey {
static let defaultValue: any Palette = SystemPalette(.green)
}
extension EnvironmentValues {
/// The current palette.
///
/// Set a palette at the app level and it propagates to all child views:
///
/// ```swift
/// WindowGroup {
/// ContentView()
/// }
/// .environment(\.palette, SystemPalette(.green))
/// ```
///
/// Access the palette in `renderToBuffer(context:)`:
///
/// ```swift
/// let palette = context.environment.palette
/// let fg = palette.foreground
/// ```
public var palette: any Palette {
get { self[PaletteKey.self] }
set { self[PaletteKey.self] = newValue }
}
}
// MARK: - Palette Registry
/// Registry of available palettes.
struct PaletteRegistry {
public struct PaletteRegistry {
/// All available palettes in cycling order, built from ``SystemPalette/Preset``.
///
/// Order: Green Amber Red Violet Blue White
static let all: [any Palette] = SystemPalette.Preset.allCases.map { SystemPalette($0) }
public static let all: [any Palette] = SystemPalette.Preset.allCases.map { SystemPalette($0) }
/// Finds a palette by ID.
static func palette(withId id: String) -> (any Palette)? {
public static func palette(withId id: String) -> (any Palette)? {
all.first { $0.id == id }
}
/// Finds a palette by name.
static func palette(withName name: String) -> (any Palette)? {
public static func palette(withName name: String) -> (any Palette)? {
all.first { $0.name == name }
}
}
// MARK: - PaletteManager Environment Key
/// Environment key for the palette manager.
private struct PaletteManagerKey: EnvironmentKey {
static let defaultValue = ThemeManager(items: PaletteRegistry.all)
}
extension EnvironmentValues {
/// The palette manager for cycling and setting palettes.
///
/// ```swift
/// let paletteManager = context.environment.paletteManager
/// paletteManager.cycleNext()
/// paletteManager.setCurrent(SystemPalette(.amber))
/// ```
public var paletteManager: ThemeManager {
get { self[PaletteManagerKey.self] }
set { self[PaletteManagerKey.self] = newValue }
}
}
@@ -57,7 +57,7 @@ public protocol Cyclable: Sendable {
/// # Render Integration
///
/// On every change the manager triggers a re-render through the
/// injected ``AppState`` instance. The `RenderLoop` picks up the
/// injected render trigger closure. The `RenderLoop` picks up the
/// current item via ``currentPalette`` / ``currentAppearance`` when
/// building the environment for the next frame.
public final class ThemeManager: @unchecked Sendable {
@@ -65,27 +65,27 @@ public final class ThemeManager: @unchecked Sendable {
private var currentIndex: Int = 0
/// All available items in cycling order.
let items: [any Cyclable]
public let items: [any Cyclable]
/// The app state used to trigger re-renders on theme changes.
private let appState: AppState
/// Closure that triggers a re-render when the theme changes.
private let renderTrigger: @Sendable () -> Void
/// Creates a theme manager with the given items.
///
/// - Parameters:
/// - items: The items to cycle through. Must not be empty.
/// - appState: The app state instance for triggering re-renders.
init(items: [any Cyclable], appState: AppState) {
/// - renderTrigger: A closure that triggers a re-render when the selection changes.
public init(items: [any Cyclable], renderTrigger: @escaping @Sendable () -> Void) {
precondition(!items.isEmpty, "ThemeManager requires at least one item")
self.items = items
self.appState = appState
self.renderTrigger = renderTrigger
}
/// Creates a theme manager with a default `AppState` instance.
/// Creates a theme manager with a no-op render trigger.
///
/// Used for environment key defaults only.
convenience init(items: [any Cyclable]) {
self.init(items: items, appState: AppState())
public convenience init(items: [any Cyclable]) {
self.init(items: items, renderTrigger: {})
}
// MARK: - Current Item
@@ -142,7 +142,7 @@ public extension ThemeManager {
private extension ThemeManager {
/// Triggers a re-render so the `RenderLoop` picks up the new current item.
func applyCurrentItem() {
appState.setNeedsRender()
renderTrigger()
}
}
@@ -11,46 +11,36 @@
/// Keeping opacity values and other visual parameters in one place ensures
/// consistency and makes global adjustments easy. All values are `Double`
/// for direct use with ``Color/opacity(_:)``.
enum ViewConstants {
public enum ViewConstants {
// MARK: - Focus & Selection Opacity
/// Minimum accent opacity during focus pulsing animation (dim phase).
static let focusPulseMin: Double = 0.35
public static let focusPulseMin: Double = 0.35
/// Maximum accent opacity during focus pulsing animation (bright phase).
static let focusPulseMax: Double = 0.50
public static let focusPulseMax: Double = 0.50
/// Background opacity for selected (but unfocused) rows.
static let selectedBackground: Double = 0.25
public static let selectedBackground: Double = 0.25
/// Background opacity for alternating row tinting.
static let alternatingRowBackground: Double = 0.15
public static let alternatingRowBackground: Double = 0.15
/// Accent opacity for focus borders and indicator caps in their dim state.
static let focusBorderDim: Double = 0.20
public static let focusBorderDim: Double = 0.20
/// Foreground opacity for disabled interactive controls.
static let disabledForeground: Double = 0.50
public static let disabledForeground: Double = 0.50
/// Accent opacity for selection indicator bullets.
static let selectionIndicator: Double = 0.60
public static let selectionIndicator: Double = 0.60
/// Accent opacity for focused button caps pulsing bright phase.
static let buttonCapPulseBright: Double = 0.45
public static let buttonCapPulseBright: Double = 0.45
// MARK: - Default Strings
/// Default placeholder text for empty List and Table views.
static let emptyListPlaceholder = "No items"
}
// MARK: - EdgeInsets Defaults
extension EdgeInsets {
/// Default insets for containers (Card, Panel, etc.): 1 horizontal, 0 vertical.
static let containerDefault = EdgeInsets(horizontal: 1, vertical: 0)
/// Default insets for dialogs: 2 horizontal, 1 vertical.
static let dialogDefault = EdgeInsets(horizontal: 2, vertical: 1)
public static let emptyListPlaceholder = "No items"
}