mirror of
https://github.com/phranck/TUIkit.git
synced 2026-05-21 09:50:35 +00:00
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:
+16
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+23
-11
@@ -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 0–360, saturation 0–100, lightness 0–100).
|
||||
///
|
||||
/// - 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)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
+1
-47
@@ -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
-65
@@ -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 }
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+10
-20
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user