Initial Commit
This commit is contained in:
commit
9f32b787c7
15 changed files with 7823 additions and 0 deletions
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.DS_Store
|
||||||
|
/.build
|
||||||
|
/Packages
|
||||||
|
xcuserdata/
|
||||||
|
DerivedData/
|
||||||
|
.swiftpm/configuration/registries.json
|
||||||
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
|
.netrc
|
||||||
49
Package.swift
Normal file
49
Package.swift
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// swift-tools-version: 6.1
|
||||||
|
// The swift-tools-version declares the minimum version of Swift required to build this package.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "ArtifactUI",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v14)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
// Products define the executables and libraries a package produces, making them visible to other packages.
|
||||||
|
.library(
|
||||||
|
name: "ArtifactUI",
|
||||||
|
targets: ["ArtifactUI"]),
|
||||||
|
],
|
||||||
|
traits: [
|
||||||
|
.default(enabledTraits: ["STB"]),
|
||||||
|
.trait(name: "STB")
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(path: "../ArtifactMath"),
|
||||||
|
.package(path: "../ArtifactColor"),
|
||||||
|
.package(path: "../ArtifactState"),
|
||||||
|
.package(path: "../ArtifactPlatform"),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||||
|
// Targets can depend on other targets in this package and products from dependencies.
|
||||||
|
.target(
|
||||||
|
name: "ArtifactUI",
|
||||||
|
dependencies: [
|
||||||
|
"ArtifactMath",
|
||||||
|
"ArtifactColor",
|
||||||
|
"ArtifactState",
|
||||||
|
"ArtifactPlatform",
|
||||||
|
.target(name: "stb_truetype", condition: .when(traits: ["STB"]))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "stb_truetype",
|
||||||
|
publicHeadersPath: "."
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "ArtifactUITests",
|
||||||
|
dependencies: ["ArtifactUI"]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
178
Sources/ArtifactUI/Animation.swift
Normal file
178
Sources/ArtifactUI/Animation.swift
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public class Timeline {
|
||||||
|
var currentTime: Double = 0
|
||||||
|
var animations: [Animation] = []
|
||||||
|
|
||||||
|
public func add(_ animation: Animation) {
|
||||||
|
animations.append(animation)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func update(deltaTime: Double) {
|
||||||
|
currentTime += deltaTime
|
||||||
|
animations.removeAll { $0.finished && $0.stop() is Void }
|
||||||
|
for anim in animations {
|
||||||
|
anim.update(currentTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct EasingFunction {
|
||||||
|
public static var linear: Self { Self { $0 } }
|
||||||
|
public static var easeIn: Self { Self { t in t * t } }
|
||||||
|
public static var easeOut: Self { Self { t in 1 - (1 - t) * (1 - t) } }
|
||||||
|
public static var easeInOut: Self { Self { t in t < 0.5 ? 2*t*t : 1 - pow(-2*t + 2, 2)/2 } }
|
||||||
|
|
||||||
|
let eased: (Double) -> Double
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Keyframe {
|
||||||
|
public let time: Double
|
||||||
|
public let values: [String: any StylePropertyType]
|
||||||
|
public let willAffectLayout: Bool
|
||||||
|
public let easing: EasingFunction? // easing *into* this keyframe from the previous one
|
||||||
|
|
||||||
|
public init(time: Double, easing: EasingFunction? = nil, @StyleBuilder values: () -> [any StylePropertyType]) {
|
||||||
|
self.init(time: time, easing: easing, values: values())
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(time: Double, easing: EasingFunction? = nil, values: [any StylePropertyType]) {
|
||||||
|
self.time = time
|
||||||
|
var willAffectLayout = false
|
||||||
|
self.values = Dictionary(uniqueKeysWithValues: values.map { willAffectLayout = $0.affectsLayout || willAffectLayout; return ($0.codingKey, $0) })
|
||||||
|
self.willAffectLayout = willAffectLayout
|
||||||
|
self.easing = easing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PropertyKeyframe {
|
||||||
|
let time: Double
|
||||||
|
let value: any StylePropertyType
|
||||||
|
let easing: EasingFunction?
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Animation {
|
||||||
|
let element: Element
|
||||||
|
var finished: Bool = false
|
||||||
|
|
||||||
|
let startTime: Double
|
||||||
|
let endTime: Double
|
||||||
|
|
||||||
|
var tracks: [String: [PropertyKeyframe]] = [:]
|
||||||
|
public var duration: Double
|
||||||
|
|
||||||
|
let easing: EasingFunction
|
||||||
|
|
||||||
|
let style = Style()
|
||||||
|
|
||||||
|
private let willAffectLayout: Bool
|
||||||
|
|
||||||
|
public convenience init(_ timeline: Timeline, _ element: Element, @StyleBuilder to: () -> [any StylePropertyType], easing: EasingFunction, duration: Double) {
|
||||||
|
self.init(timeline, element, to: to(), easing: easing, duration: duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(_ timeline: Timeline, _ element: Element, @StyleBuilder from: () -> [any StylePropertyType], @StyleBuilder to: () -> [any StylePropertyType], easing: EasingFunction, duration: Double) {
|
||||||
|
self.init(timeline, element, from: from(), to: to(), easing: easing, duration: duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(_ timeline: Timeline, _ element: Element, to: [any StylePropertyType], easing: EasingFunction, duration: Double) {
|
||||||
|
self.init(timeline, element, from: to.map { $0.read(from: element.presentationStyle) }, to: to, easing: easing, duration: duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(_ timeline: Timeline, _ element: Element, from: [any StylePropertyType], to: [any StylePropertyType], easing: EasingFunction, duration: Double) {
|
||||||
|
self.init(timeline, element, keyframes: [Keyframe(time: 0, values: from), Keyframe(time: 1, values: to)], easing: easing, duration: duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ timeline: Timeline, _ element: Element, keyframes: [Keyframe], easing: EasingFunction, duration: Double) {
|
||||||
|
self.element = element
|
||||||
|
self.duration = duration
|
||||||
|
self.startTime = timeline.currentTime
|
||||||
|
self.endTime = startTime + duration
|
||||||
|
self.easing = easing
|
||||||
|
var willAffectLayout = false
|
||||||
|
for keyframe in keyframes {
|
||||||
|
willAffectLayout = keyframe.willAffectLayout || willAffectLayout
|
||||||
|
for (key, prop) in keyframe.values {
|
||||||
|
tracks[key, default: []].append(PropertyKeyframe(time: keyframe.time, value: prop, easing: keyframe.easing))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.willAffectLayout = willAffectLayout
|
||||||
|
|
||||||
|
if element._presentationStyle == nil {
|
||||||
|
element._presentationStyle = Style()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() {
|
||||||
|
for key in tracks.keys {
|
||||||
|
element._presentationStyle?.storage[key] = nil
|
||||||
|
}
|
||||||
|
if let empty = element._presentationStyle?.storage.isEmpty, empty {
|
||||||
|
element._presentationStyle = nil
|
||||||
|
}
|
||||||
|
if willAffectLayout {
|
||||||
|
element.setNeedsLayout()
|
||||||
|
element.setNeedsDisplay()
|
||||||
|
}
|
||||||
|
element.setNeedsDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cancel() {
|
||||||
|
finished = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public func commit() {
|
||||||
|
cancel()
|
||||||
|
element.style.merge(with: style)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func segment(for t: Double, in track: [PropertyKeyframe]) -> (from: PropertyKeyframe, to: PropertyKeyframe, progress: Double)? {
|
||||||
|
|
||||||
|
guard let first = track.first else { return nil }
|
||||||
|
if t < first.time { return nil } // not yet controlled
|
||||||
|
|
||||||
|
if t >= track.last!.time {
|
||||||
|
let last = track.last!
|
||||||
|
return (last, last, 1.0) // hold final value
|
||||||
|
}
|
||||||
|
|
||||||
|
// linear scan is fine (tracks are short); binary search if you ever have dozens
|
||||||
|
for i in 0..<track.count-1 {
|
||||||
|
let a = track[i]
|
||||||
|
let b = track[i+1]
|
||||||
|
if t >= a.time && t < b.time {
|
||||||
|
let raw = (t - a.time) / (b.time - a.time)
|
||||||
|
let eased = (b.easing ?? easing).eased(raw) // or a.easing – pick the convention you like
|
||||||
|
return (a, b, eased)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(_ currentTime: Double) {
|
||||||
|
guard !finished, currentTime >= startTime else { return }
|
||||||
|
|
||||||
|
let localTime = min(currentTime - startTime, duration)
|
||||||
|
if localTime >= duration { finished = true }
|
||||||
|
|
||||||
|
for (_, track) in tracks {
|
||||||
|
guard let (from, to, progress) = segment(for: localTime / duration, in: track) else {
|
||||||
|
// before first keyframe → leave the property alone
|
||||||
|
// (or snap to first value if you prefer)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let lerped = from.value.lerp(to: to.value, progress: progress)
|
||||||
|
lerped.write(into: style)
|
||||||
|
}
|
||||||
|
|
||||||
|
element._presentationStyle?.merge(with: style)
|
||||||
|
if willAffectLayout {
|
||||||
|
element.setNeedsLayout()
|
||||||
|
element.setNeedsDisplay()
|
||||||
|
}
|
||||||
|
element.setNeedsDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
241
Sources/ArtifactUI/Context.swift
Normal file
241
Sources/ArtifactUI/Context.swift
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
|
||||||
|
import ArtifactMath
|
||||||
|
import ArtifactState
|
||||||
|
import ArtifactPlatform
|
||||||
|
|
||||||
|
public final class UIElementRegistry {
|
||||||
|
public static nonisolated(unsafe) let shared = UIElementRegistry()
|
||||||
|
|
||||||
|
public var mapping: [String: Element.Type] = [:]
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
register(Element.self)
|
||||||
|
register(Text.self)
|
||||||
|
register(Stack.self)
|
||||||
|
register(ScrollView.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func register<T: Element>(_ type: T.Type) {
|
||||||
|
mapping[type.elementName] = type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UIContext: Element {
|
||||||
|
public private(set) var viewportSize: Vec2d = .zero
|
||||||
|
public var drawCursor = false
|
||||||
|
public var timeline = Timeline()
|
||||||
|
public var styleSheet: StyleSheet
|
||||||
|
public var textProvider: any UITextProvider
|
||||||
|
|
||||||
|
public var input: InputState = InputState()
|
||||||
|
var pointersDown: [PointerState.ID: (target: Element, position: Vec2f, time: Double)] = [:]
|
||||||
|
var hoverTarget: [PointerState.ID: Element] = [:]
|
||||||
|
|
||||||
|
var postCommands: [UIDrawCommand] = []
|
||||||
|
|
||||||
|
var commands: [UIDrawCommand] = []
|
||||||
|
|
||||||
|
var changeHandlers: [([UIChange]) -> Void] = []
|
||||||
|
|
||||||
|
// The context is an Element, but it doesn't need to notify itself when these change.
|
||||||
|
public override func setNeedsLayout() {
|
||||||
|
needsLayout = true
|
||||||
|
}
|
||||||
|
public override func setNeedsDisplay() {
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(_ styleSheet: StyleSheet = StyleSheet(), @UIBuilder children: () -> [Element]) {
|
||||||
|
self.init(STBTextProvider(), styleSheet: styleSheet, children: children())
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(_ textProvider: any UITextProvider, _ styleSheet: StyleSheet = StyleSheet(), @UIBuilder children: () -> [Element]) {
|
||||||
|
self.init(textProvider, styleSheet: styleSheet, children: children())
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ textProvider: any UITextProvider, styleSheet: StyleSheet, children: [Element] = []) {
|
||||||
|
self.textProvider = textProvider
|
||||||
|
self.styleSheet = styleSheet
|
||||||
|
super.init(children: children)
|
||||||
|
context = self
|
||||||
|
for child in children {
|
||||||
|
child.context = self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setViewportSize(to size: Vec2d) {
|
||||||
|
viewportSize = size
|
||||||
|
frame.size = viewportSize
|
||||||
|
layoutChildren()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func onChange(_ handler: @escaping ([UIChange]) -> Void) {
|
||||||
|
changeHandlers.append(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func update(deltaTime: Double) {
|
||||||
|
timeline.update(deltaTime: deltaTime)
|
||||||
|
|
||||||
|
if needsLayout {
|
||||||
|
layoutChildren() // or a full measure/layout pass
|
||||||
|
needsLayout = false
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getDrawList() -> UIDrawList {
|
||||||
|
needsDisplay = false
|
||||||
|
var list = UIDrawList()
|
||||||
|
let commands = collectDrawCommands() + postCommands
|
||||||
|
|
||||||
|
for cmd in commands {
|
||||||
|
switch cmd {
|
||||||
|
case .fillRect(let rect, let color, let cornerRadius):
|
||||||
|
list.fill(path: .rect(rect, cornerRadius: cornerRadius), color: color, antialiased: cornerRadius > 0)
|
||||||
|
case .strokeRect(let rect, let color, let thickness, let cornerRadius):
|
||||||
|
list.stroke(path: .rect(rect.inset(by: thickness / 2), cornerRadius: max(0, cornerRadius - thickness / 2)), thickness: thickness, color: color, antialiased: cornerRadius > 0)
|
||||||
|
case .fillPath(let path, let color):
|
||||||
|
list.fill(path: path, color: color)
|
||||||
|
case .strokePath(let path, let color, let thickness):
|
||||||
|
list.stroke(path: path, thickness: thickness, color: color)
|
||||||
|
case .text(let rect, let text, let color, let fontName, let fontSize, let alignment):
|
||||||
|
textProvider.drawText(into: &list, rect: rect, text: text, color: color, fontName: fontName, fontSize: fontSize, alignment: alignment)
|
||||||
|
case .clip(let rect):
|
||||||
|
list.pushClip(rect: rect.clamped([0, 0], viewportSize))
|
||||||
|
case .popClip:
|
||||||
|
list.popClip()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list.end()
|
||||||
|
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func hitTest(_ point: Vec2d) -> Element? {
|
||||||
|
for child in children! {
|
||||||
|
if let hit = child.hitTest(point) {
|
||||||
|
return hit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DerivedElement: Element {
|
||||||
|
var derivation: () -> Element
|
||||||
|
var element: Element! {
|
||||||
|
didSet {
|
||||||
|
element.parent = parent
|
||||||
|
element.context = context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override var context: UIContext? {
|
||||||
|
didSet {
|
||||||
|
element?.context = context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override public var parent: Element? {
|
||||||
|
didSet {
|
||||||
|
element?.parent = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var depends: [any BindingProtocol] = []
|
||||||
|
public override var frame: Rect<Double> {
|
||||||
|
didSet {
|
||||||
|
element.frame = frame
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(derivation: @escaping () -> Element) {
|
||||||
|
self.derivation = derivation
|
||||||
|
super.init()
|
||||||
|
observing(into: &depends) {
|
||||||
|
element = derivation()
|
||||||
|
element.context = context
|
||||||
|
element.parent = parent
|
||||||
|
element.frame = frame
|
||||||
|
}
|
||||||
|
for dep in depends {
|
||||||
|
dep.subscribe(key: ObjectIdentifier(self), handler: recompute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
for dep in depends {
|
||||||
|
dep.unsubscribe(key: ObjectIdentifier(self))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func draw(offset: Vec2d) -> [UIDrawCommand] {
|
||||||
|
element.draw(offset: offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func layoutChildren() {
|
||||||
|
element.layoutChildren()
|
||||||
|
}
|
||||||
|
|
||||||
|
func recompute() {
|
||||||
|
for dep in depends {
|
||||||
|
dep.unsubscribe(key: ObjectIdentifier(self))
|
||||||
|
}
|
||||||
|
depends.removeAll()
|
||||||
|
|
||||||
|
observing(into: &depends) {
|
||||||
|
element = derivation()
|
||||||
|
element.context = context
|
||||||
|
element.parent = parent
|
||||||
|
element.frame = frame
|
||||||
|
}
|
||||||
|
|
||||||
|
for dep in depends {
|
||||||
|
dep.subscribe(key: ObjectIdentifier(self), handler: recompute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func dump(_ indent: Int = 0) {
|
||||||
|
print("\(String(repeating: " ", count: indent * 4))@derived")
|
||||||
|
element.dump(indent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@resultBuilder
|
||||||
|
public struct UIBuilder {
|
||||||
|
public static func buildBlock(_ components: Element...) -> [Element] {
|
||||||
|
components
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildBlock(_ components: [Element]) -> [Element] {
|
||||||
|
components
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildPartialBlock(first: [Element]) -> [Element] {
|
||||||
|
first
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildPartialBlock(first: Element) -> [Element] {
|
||||||
|
[first]
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildPartialBlock(accumulated: [Element], next: [Element]) -> [Element] {
|
||||||
|
accumulated + next
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildPartialBlock(accumulated: [Element], next: Element) -> [Element] {
|
||||||
|
accumulated + [next]
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildExpression(_ expression: @escaping () -> Element) -> Element {
|
||||||
|
DerivedElement(derivation: expression)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildExpression(_ expression: Element) -> Element {
|
||||||
|
expression
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildArray(_ components: [[Element]]) -> [Element] {
|
||||||
|
components.flatMap { $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
7
Sources/ArtifactUI/Diffs.swift
Normal file
7
Sources/ArtifactUI/Diffs.swift
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum UIChange: Codable {
|
||||||
|
case insert(path: [UUID], index: Int)
|
||||||
|
case remove(path: [UUID])
|
||||||
|
}
|
||||||
403
Sources/ArtifactUI/Drawing.swift
Normal file
403
Sources/ArtifactUI/Drawing.swift
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
|
||||||
|
import ArtifactMath
|
||||||
|
import Foundation
|
||||||
|
import ArtifactColor
|
||||||
|
|
||||||
|
public enum UIDrawCommand {
|
||||||
|
case fillRect(rect: Rect<Double>, color: Color, cornerRadius: Double = 0)
|
||||||
|
case strokeRect(rect: Rect<Double>, color: Color, thickness: Double, cornerRadius: Double = 0)
|
||||||
|
|
||||||
|
case fillPath(path: UIPath, color: Color)
|
||||||
|
case strokePath(path: UIPath, color: Color, thickness: Double)
|
||||||
|
|
||||||
|
case text(
|
||||||
|
rect: Rect<Double>, // bounding box / clip
|
||||||
|
text: String,
|
||||||
|
color: Color = .black,
|
||||||
|
fontName: String? = nil, // or font descriptor
|
||||||
|
fontSize: Float,
|
||||||
|
alignment: Alignment = .leading
|
||||||
|
)
|
||||||
|
|
||||||
|
case image(
|
||||||
|
rect: Rect<Double>,
|
||||||
|
image: Int,
|
||||||
|
tint: Color? = nil
|
||||||
|
)
|
||||||
|
|
||||||
|
case clip(rect: Rect<Double>) // push clip rect (stack-based)
|
||||||
|
case popClip // pop last clip
|
||||||
|
|
||||||
|
// case transform(matrix: TransformMatrix)
|
||||||
|
// case pushTransform
|
||||||
|
// case popTransform
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct UIPath {
|
||||||
|
public enum Command {
|
||||||
|
case move(to: Vec2d)
|
||||||
|
case line(to: Vec2d)
|
||||||
|
case quadratic(control: Vec2d, end: Vec2d)
|
||||||
|
case cubic(control1: Vec2d, control2: Vec2d, end: Vec2d)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func rect(_ rect: Rect<Double>, cornerRadius: Double) -> UIPath {
|
||||||
|
var p = UIPath()
|
||||||
|
let radius = min(cornerRadius, min(rect.size.x, rect.size.y) / 2.0)
|
||||||
|
|
||||||
|
if radius > 0 {
|
||||||
|
let x = rect.origin.x
|
||||||
|
let y = rect.origin.y
|
||||||
|
let w = rect.size.x
|
||||||
|
let h = rect.size.y
|
||||||
|
|
||||||
|
p.move(to: Vec2d(x + radius, y))
|
||||||
|
|
||||||
|
p.line(to: Vec2d(x + w - radius, y)) // top
|
||||||
|
p.quadCurve(to: Vec2d(x + w, y + radius), control: Vec2d(x + w, y))
|
||||||
|
|
||||||
|
p.line(to: Vec2d(x + w, y + h - radius)) // right
|
||||||
|
p.quadCurve(to: Vec2d(x + w - radius, y + h), control: Vec2d(x + w, y + h))
|
||||||
|
|
||||||
|
p.line(to: Vec2d(x + radius, y + h)) // bottom
|
||||||
|
p.quadCurve(to: Vec2d(x, y + h - radius), control: Vec2d(x, y + h))
|
||||||
|
|
||||||
|
p.line(to: Vec2d(x, y + radius)) // left
|
||||||
|
p.quadCurve(to: Vec2d(x + radius, y), control: Vec2d(x, y))
|
||||||
|
} else {
|
||||||
|
p.move(to: rect.origin)
|
||||||
|
p.line(to: rect.origin + (rect.size.x, 0))
|
||||||
|
p.line(to: rect.origin + rect.size)
|
||||||
|
p.line(to: rect.origin + (0, rect.size.y))
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public private(set) var commands: [Command] = []
|
||||||
|
|
||||||
|
public mutating func move(to point: Vec2d) {
|
||||||
|
commands.append(.move(to: point))
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func line(to point: Vec2d) {
|
||||||
|
commands.append(.line(to: point))
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func quadCurve(to end: Vec2d, control: Vec2d) {
|
||||||
|
commands.append(.quadratic(control: control, end: end))
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func cubicCurve(to end: Vec2d, control1: Vec2d, control2: Vec2d) {
|
||||||
|
commands.append(.cubic(control1: control1, control2: control2, end: end))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func flattened(tolerance: Double = 0.5) -> [[Vec2d]] {
|
||||||
|
var contours: [[Vec2d]] = []
|
||||||
|
var current: [Vec2d] = []
|
||||||
|
var cursor = Vec2d()
|
||||||
|
|
||||||
|
for cmd in commands {
|
||||||
|
switch cmd {
|
||||||
|
case .move(let point):
|
||||||
|
if !current.isEmpty {
|
||||||
|
contours.append(current)
|
||||||
|
}
|
||||||
|
current = [point]
|
||||||
|
cursor = point
|
||||||
|
case .line(let point):
|
||||||
|
current.append(point)
|
||||||
|
cursor = point
|
||||||
|
case .quadratic(let control, let end):
|
||||||
|
flattenQuadratic(start: cursor, control: control, end: end, tolerance: tolerance, into: ¤t)
|
||||||
|
cursor = end
|
||||||
|
case .cubic(let c1, let c2, let end):
|
||||||
|
flattenCubic(start: cursor, c1: c1, c2: c2, end: end, tolerance: tolerance, into: ¤t)
|
||||||
|
cursor = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !current.isEmpty {
|
||||||
|
contours.append(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return contours
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flattenQuadratic(start: Vec2d, control: Vec2d, end: Vec2d, tolerance: Double, into out: inout [Vec2d]) {
|
||||||
|
// Simple flatness test for quadratic
|
||||||
|
let dx = end.x - start.x
|
||||||
|
let dy = end.y - start.y
|
||||||
|
let d = abs((control.x - end.x) * dy - (control.y - end.y) * dx)
|
||||||
|
if d * d < tolerance * tolerance * (dx * dx + dy * dy) {
|
||||||
|
out.append(end)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subdivide
|
||||||
|
let mid1 = (start + control) * 0.5
|
||||||
|
let mid2 = (control + end) * 0.5
|
||||||
|
let mid = (mid1 + mid2) * 0.5
|
||||||
|
|
||||||
|
flattenQuadratic(start: start, control: mid1, end: mid, tolerance: tolerance, into: &out)
|
||||||
|
flattenQuadratic(start: mid, control: mid2, end: end, tolerance: tolerance, into: &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flattenCubic(start: Vec2d, c1: Vec2d, c2: Vec2d, end: Vec2d, tolerance: Double, into out: inout [Vec2d]) {
|
||||||
|
// Classic flatness test (from AGG / many vector libs)
|
||||||
|
let dx = end.x - start.x
|
||||||
|
let dy = end.y - start.y
|
||||||
|
let d2 = abs((c1.x - end.x) * dy - (c1.y - end.y) * dx)
|
||||||
|
let d3 = abs((c2.x - end.x) * dy - (c2.y - end.y) * dx)
|
||||||
|
|
||||||
|
if (d2 + d3) * (d2 + d3) < tolerance * tolerance * (dx * dx + dy * dy) {
|
||||||
|
out.append(end)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subdivide using De Casteljau
|
||||||
|
let mid1 = (start + c1) * 0.5
|
||||||
|
let mid2 = (c1 + c2) * 0.5
|
||||||
|
let mid3 = (c2 + end) * 0.5
|
||||||
|
|
||||||
|
let mid12 = (mid1 + mid2) * 0.5
|
||||||
|
let mid23 = (mid2 + mid3) * 0.5
|
||||||
|
let mid123 = (mid12 + mid23) * 0.5
|
||||||
|
|
||||||
|
flattenCubic(start: start, c1: mid1, c2: mid12, end: mid123, tolerance: tolerance, into: &out)
|
||||||
|
flattenCubic(start: mid123, c1: mid23, c2: mid3, end: end, tolerance: tolerance, into: &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct UIDrawList {
|
||||||
|
public enum Operation {
|
||||||
|
case draw(count: Int)
|
||||||
|
case pushScissor(rect: Rect<Float>)
|
||||||
|
case popScissor
|
||||||
|
case pushTransform(_ transformation: Mat4f)
|
||||||
|
case popTransform
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
public var vertices: [UInt8]
|
||||||
|
public var vertexCount: Int
|
||||||
|
public var indices: [UInt32]
|
||||||
|
public var indexCount: Int
|
||||||
|
public var operations: [Operation] = []
|
||||||
|
public var scissor: [Rect<Float>] = []
|
||||||
|
|
||||||
|
public init(vertices: [UInt8] = [], vertexCount: Int = 0, indices: [UInt32] = [], indexCount: Int = 0) {
|
||||||
|
self.vertices = vertices
|
||||||
|
self.vertexCount = vertexCount
|
||||||
|
self.indices = indices
|
||||||
|
self.indexCount = indexCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFloat(to dest: inout [UInt8], _ float: Float) {
|
||||||
|
withUnsafeBytes(of: float) { dest.append(contentsOf: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Make this support user-defined layout.
|
||||||
|
public mutating func writeVertex(pos: Vec2d, uv: Vec2d, color: Color) {
|
||||||
|
writeFloat(to: &vertices, Float(pos.x))
|
||||||
|
writeFloat(to: &vertices, Float(pos.y))
|
||||||
|
writeFloat(to: &vertices, Float(uv.x))
|
||||||
|
writeFloat(to: &vertices, Float(uv.y))
|
||||||
|
vertices.append(color.r)
|
||||||
|
vertices.append(color.g)
|
||||||
|
vertices.append(color.b)
|
||||||
|
vertices.append(color.a)
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func addQuad(bounds: Rect<Double>, uv: Rect<Double>, color: Color) {
|
||||||
|
writeVertex(pos: bounds.origin, uv: uv.origin, color: color)
|
||||||
|
writeVertex(pos: bounds.origin + (bounds.size.x, 0), uv: uv.origin + (uv.size.x, 0), color: color)
|
||||||
|
writeVertex(pos: bounds.origin + bounds.size, uv: uv.origin + uv.size, color: color)
|
||||||
|
writeVertex(pos: bounds.origin + (0, bounds.size.y), uv: uv.origin + (0, uv.size.y), color: color)
|
||||||
|
|
||||||
|
let base = UInt32(vertexCount)
|
||||||
|
indices.append(base + 2); indices.append(base + 1); indices.append(base + 0)
|
||||||
|
indices.append(base + 3); indices.append(base + 2); indices.append(base + 0)
|
||||||
|
|
||||||
|
vertexCount += 4
|
||||||
|
indexCount = indices.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeNormals(for contour: [Vec2d]) -> [Vec2d] {
|
||||||
|
var normals = [Vec2d](repeating: .zero, count: contour.count)
|
||||||
|
for i in 0..<contour.count {
|
||||||
|
let p0 = contour[i]
|
||||||
|
let p1 = contour[(i + 1) % contour.count]
|
||||||
|
let diff = p1 - p0
|
||||||
|
var len = diff.length
|
||||||
|
if len == 0 {
|
||||||
|
normals[i] = i > 0 ? normals[i-1] : normals.last!
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
len = 1 / len
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir = diff * len
|
||||||
|
|
||||||
|
normals[i] = Vec2d(dir.y, -dir.x)
|
||||||
|
}
|
||||||
|
return normals
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func fill(path: UIPath, color: Color, antialiased: Bool = true) {
|
||||||
|
let contours = path.flattened(tolerance: 0.75)
|
||||||
|
|
||||||
|
for var contour in contours {
|
||||||
|
guard contour.count >= 3 else { continue }
|
||||||
|
|
||||||
|
contour = contour.map { $0.applying(floor) }
|
||||||
|
|
||||||
|
// Add vertices
|
||||||
|
for point in contour {
|
||||||
|
writeVertex(pos: point, uv: Vec2d(), color: color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan triangulation (convex)
|
||||||
|
let base = UInt32(vertexCount)
|
||||||
|
|
||||||
|
for i in 1..<(contour.count - 1) {
|
||||||
|
indices.append(base + UInt32(i + 1))
|
||||||
|
indices.append(base + UInt32(i))
|
||||||
|
indices.append(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
vertexCount += contour.count
|
||||||
|
|
||||||
|
if antialiased {
|
||||||
|
let aaWidth = 1.0
|
||||||
|
let outerColor = Color(r: color.r, g: color.g, b: color.b, a: 0)
|
||||||
|
|
||||||
|
let normals = computeNormals(for: contour)
|
||||||
|
|
||||||
|
let aaBase = vertexCount // index of first outer vertex
|
||||||
|
for i in 0..<contour.count {
|
||||||
|
let n = normals[i] // For better corners, average with previous
|
||||||
|
let prevN = normals[(i + contour.count - 1) % contour.count]
|
||||||
|
var normal = (n + prevN) * 0.5
|
||||||
|
let normal2 = Vec2.dot(normal, normal)
|
||||||
|
|
||||||
|
if normal2 > 0.00001 {
|
||||||
|
normal *= min(100, 1 / normal2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let outerPos = contour[i] + normal * aaWidth * 0.5 // outward extrusion
|
||||||
|
writeVertex(pos: outerPos, uv: .zero, color: outerColor)
|
||||||
|
}
|
||||||
|
vertexCount += contour.count
|
||||||
|
|
||||||
|
for i in 0..<contour.count {
|
||||||
|
let i0 = Int(base) + i
|
||||||
|
let i1 = Int(base) + (i + 1) % contour.count
|
||||||
|
let o0 = aaBase + i
|
||||||
|
let o1 = aaBase + (i + 1) % contour.count
|
||||||
|
|
||||||
|
// Quad: inner0 -> inner1 -> outer1 -> outer0
|
||||||
|
indices.append(UInt32(i0)); indices.append(UInt32(i1)); indices.append(UInt32(o1))
|
||||||
|
indices.append(UInt32(i0)); indices.append(UInt32(o1)); indices.append(UInt32(o0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexCount = indices.count
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func stroke(path: UIPath, thickness: Double, color: Color, antialiased: Bool = true) {
|
||||||
|
let thickness = max(thickness, 0.01)
|
||||||
|
let half = thickness * 0.5
|
||||||
|
let contours = path.flattened(tolerance: 0.75) // tighter tolerance for strokes
|
||||||
|
|
||||||
|
for contour in contours {
|
||||||
|
guard contour.count >= 2 else { continue }
|
||||||
|
|
||||||
|
let n = contour.count
|
||||||
|
let first = UInt32(vertexCount)
|
||||||
|
|
||||||
|
let normals = computeNormals(for: contour)
|
||||||
|
|
||||||
|
for i in 0..<n {
|
||||||
|
let p0 = contour[i]
|
||||||
|
|
||||||
|
let currentN = normals[i] // For better corners, average with previous
|
||||||
|
let prevN = normals[(i + contour.count - 1) % contour.count]
|
||||||
|
var normal = (currentN + prevN) * 0.5
|
||||||
|
let normal2 = Vec2.dot(normal, normal)
|
||||||
|
|
||||||
|
if normal2 > 0.00001 {
|
||||||
|
normal *= min(100, 1 / normal2)
|
||||||
|
}
|
||||||
|
|
||||||
|
let v0 = p0 - normal * half
|
||||||
|
let v1 = p0 + normal * half
|
||||||
|
|
||||||
|
let base = UInt32(vertexCount)
|
||||||
|
|
||||||
|
writeVertex(pos: v0, uv: .zero, color: color)
|
||||||
|
writeVertex(pos: v1, uv: .zero, color: color)
|
||||||
|
|
||||||
|
let o0 = i == n-1 ? first + 0 : (antialiased ? 2 : 0) + base + 2
|
||||||
|
let o1 = i == n-1 ? first + 1 : (antialiased ? 2 : 0) + base + 3
|
||||||
|
|
||||||
|
// Two triangles per segment
|
||||||
|
indices.append(o0); indices.append(base + 1); indices.append(base + 0)
|
||||||
|
indices.append(o1); indices.append(base + 1); indices.append(o0)
|
||||||
|
|
||||||
|
vertexCount += 2
|
||||||
|
|
||||||
|
if antialiased {
|
||||||
|
let aaWidth = 1.0
|
||||||
|
let outerColor = Color(color.r, color.g, color.b, 0)
|
||||||
|
let aaBase = UInt32(vertexCount)
|
||||||
|
|
||||||
|
let v0_outer = p0 - normal * (half + aaWidth * 0.5)
|
||||||
|
let v1_outer = p0 + normal * (half + aaWidth * 0.5)
|
||||||
|
|
||||||
|
let o0_outer = o0 + 2
|
||||||
|
let o1_outer = o1 + 2
|
||||||
|
|
||||||
|
writeVertex(pos: v0_outer, uv: .zero, color: outerColor)
|
||||||
|
writeVertex(pos: v1_outer, uv: .zero, color: outerColor)
|
||||||
|
|
||||||
|
indices.append(o0); indices.append(base + 0); indices.append(o0_outer)
|
||||||
|
indices.append(base + 0); indices.append(aaBase + 0); indices.append(o0_outer)
|
||||||
|
|
||||||
|
indices.append(base + 1); indices.append(o1); indices.append(o1_outer)
|
||||||
|
indices.append(aaBase + 1); indices.append(base + 1); indices.append(o1_outer)
|
||||||
|
|
||||||
|
vertexCount += 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
indexCount = indices.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentClip: [Rect<Double>] = []
|
||||||
|
|
||||||
|
public mutating func pushClip(rect: Rect<Double>) {
|
||||||
|
var rect = rect
|
||||||
|
if let lastClip = currentClip.last {
|
||||||
|
rect = rect.clamped(lastClip.origin, [lastClip.right, lastClip.bottom])
|
||||||
|
}
|
||||||
|
currentClip.append(rect)
|
||||||
|
operations.append(.draw(count: indices.count - offset))
|
||||||
|
offset = indices.count
|
||||||
|
operations.append(.pushScissor(rect: Rect<Float>(rect)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func popClip() {
|
||||||
|
currentClip.removeLast()
|
||||||
|
operations.append(.draw(count: indices.count - offset))
|
||||||
|
offset = indices.count
|
||||||
|
operations.append(.popScissor)
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func end() {
|
||||||
|
operations.append(.draw(count: indices.count - offset))
|
||||||
|
}
|
||||||
|
}
|
||||||
764
Sources/ArtifactUI/Element.swift
Normal file
764
Sources/ArtifactUI/Element.swift
Normal file
|
|
@ -0,0 +1,764 @@
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ArtifactMath
|
||||||
|
import ArtifactColor
|
||||||
|
import ArtifactState
|
||||||
|
|
||||||
|
public protocol ElementProtocol {
|
||||||
|
static var elementName: String { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ElementProtocol {
|
||||||
|
public static var elementName: String { String(reflecting: Self.self) }
|
||||||
|
}
|
||||||
|
|
||||||
|
open class Element: ElementProtocol, Hashable {
|
||||||
|
public static func == (_ left: Element, _ right: Element) -> Bool {
|
||||||
|
left.id == right.id
|
||||||
|
}
|
||||||
|
|
||||||
|
let id: UUID
|
||||||
|
var context: UIContext? {
|
||||||
|
didSet {
|
||||||
|
if context != nil && parent == nil {
|
||||||
|
style.rebase(onto: context!.style)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let children = children {
|
||||||
|
for child in children {
|
||||||
|
child.context = context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public internal(set) var parent: Element? {
|
||||||
|
didSet {
|
||||||
|
if parent != nil {
|
||||||
|
style.rebase(onto: parent!.style)
|
||||||
|
} else if context != nil {
|
||||||
|
style.rebase(onto: context!.style)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public private(set) var children: [Element]?
|
||||||
|
public private(set) var style: Style = Style()
|
||||||
|
private var _effectiveStyle: Style? { didSet { _presentationStyle?.chain(onto: effectiveStyle) } }
|
||||||
|
var _presentationStyle: Style? { didSet { _presentationStyle?.chain(onto: effectiveStyle) } }
|
||||||
|
var animations: [Animation] = []
|
||||||
|
|
||||||
|
public var effectiveStyle: Style {
|
||||||
|
if let cached = _effectiveStyle {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
guard let context = context else {
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
let resolved = context.styleSheet.resolveStyle(for: self)
|
||||||
|
resolved.rebase(onto: style)
|
||||||
|
_effectiveStyle = resolved
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
public var presentationStyle: Style {
|
||||||
|
_presentationStyle ?? effectiveStyle
|
||||||
|
}
|
||||||
|
|
||||||
|
public func invalidateStyle() {
|
||||||
|
_effectiveStyle = nil
|
||||||
|
if _presentationStyle != nil {
|
||||||
|
_presentationStyle?.chain(onto: effectiveStyle)
|
||||||
|
}
|
||||||
|
// Optionally propagate to children if inheritance is deep
|
||||||
|
// children?.forEach { $0.invalidateStyle() }
|
||||||
|
}
|
||||||
|
|
||||||
|
public var type: String { Self.elementName }
|
||||||
|
public var groups: Set<String> = [] { didSet { invalidateStyle() } }
|
||||||
|
public var frame = Rect<Double>()
|
||||||
|
public func contentFrame(forResolvedStyle style: Style) -> Rect<Double> {
|
||||||
|
let offset = Vec2d(style[PaddingLeftStyle.self] + style[BorderWidthStyle.self], style[PaddingTopStyle.self] + style[BorderWidthStyle.self])
|
||||||
|
return Rect(
|
||||||
|
origin: frame.origin + offset,
|
||||||
|
size: (frame.size - offset - [style[PaddingRightStyle.self] + style[BorderWidthStyle.self], style[PaddingBottomStyle.self] + style[BorderWidthStyle.self]]).applying { max($0, 0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var onPointerDown: [(inout UIPointerEvent) -> Void]? = nil
|
||||||
|
var onPointerMove: [(inout UIPointerEvent) -> Void]? = nil
|
||||||
|
var onPointerUp: [(inout UIPointerEvent) -> Void]? = nil
|
||||||
|
var onPointerEnter: [(inout UIPointerEvent) -> Void]? = nil
|
||||||
|
var onPointerLeave: [(inout UIPointerEvent) -> Void]? = nil
|
||||||
|
var onClick: [(inout UIPointerEvent) -> Void]? = nil
|
||||||
|
|
||||||
|
public internal(set) var needsLayout = false
|
||||||
|
public func setNeedsLayout() {
|
||||||
|
needsLayout = true
|
||||||
|
context?.needsLayout = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public internal(set) var needsDisplay = false
|
||||||
|
public func setNeedsDisplay() {
|
||||||
|
needsDisplay = true
|
||||||
|
context?.needsDisplay = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(id: UUID = .init()) {
|
||||||
|
self.id = id
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(groups: Set<String> = [], @UIBuilder children: () -> [Element]) {
|
||||||
|
self.init(groups: groups, children: children())
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(_ children: Element...) {
|
||||||
|
self.init(children: children)
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(id: UUID = .init(), groups: Set<String> = [], _ children: Element...) {
|
||||||
|
self.init(id: id, children: children)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(id: UUID = .init(), groups: Set<String> = [], children: [Element]) {
|
||||||
|
self.id = id
|
||||||
|
self.groups = groups
|
||||||
|
self.children = children
|
||||||
|
for child in children {
|
||||||
|
child.context = context
|
||||||
|
child.parent = self
|
||||||
|
}
|
||||||
|
style.onChange = { affectsLayout in
|
||||||
|
self.invalidateStyle()
|
||||||
|
if affectsLayout { self.setNeedsLayout() }
|
||||||
|
self.setNeedsDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func width(_ size: LayoutSize) -> Self {
|
||||||
|
style[WidthStyle.self] = size
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func height(_ size: LayoutSize) -> Self {
|
||||||
|
style[HeightStyle.self] = size
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func backgroundColor(_ color: Color) -> Self {
|
||||||
|
style[BackgroundColorStyle.self] = color
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func padding(_ values: Double...) -> Self {
|
||||||
|
switch values.count {
|
||||||
|
case 0:
|
||||||
|
break
|
||||||
|
case 1:
|
||||||
|
style[PaddingTopStyle.self] = values[0]
|
||||||
|
style[PaddingBottomStyle.self] = values[0]
|
||||||
|
style[PaddingLeftStyle.self] = values[0]
|
||||||
|
style[PaddingRightStyle.self] = values[0]
|
||||||
|
case 2:
|
||||||
|
style[PaddingTopStyle.self] = values[0]
|
||||||
|
style[PaddingBottomStyle.self] = values[0]
|
||||||
|
style[PaddingLeftStyle.self] = values[1]
|
||||||
|
style[PaddingRightStyle.self] = values[1]
|
||||||
|
case 3:
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
style[PaddingTopStyle.self] = values[0]
|
||||||
|
style[PaddingBottomStyle.self] = values[1]
|
||||||
|
style[PaddingLeftStyle.self] = values[2]
|
||||||
|
style[PaddingRightStyle.self] = values[3]
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func onPointerDown(_ handler: @escaping (inout UIPointerEvent) -> Void) -> Self {
|
||||||
|
if onPointerDown == nil { onPointerDown = [] }
|
||||||
|
onPointerDown?.append(handler)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func onPointerMove(_ handler: @escaping (inout UIPointerEvent) -> Void) -> Self {
|
||||||
|
if onPointerMove == nil { onPointerMove = [] }
|
||||||
|
onPointerMove?.append(handler)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func onPointerUp(_ handler: @escaping (inout UIPointerEvent) -> Void) -> Self {
|
||||||
|
if onPointerUp == nil { onPointerUp = [] }
|
||||||
|
onPointerUp?.append(handler)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func onPointerEnter(_ handler: @escaping (inout UIPointerEvent) -> Void) -> Self {
|
||||||
|
if onPointerEnter == nil { onPointerEnter = [] }
|
||||||
|
onPointerEnter?.append(handler)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func onPointerLeave(_ handler: @escaping (inout UIPointerEvent) -> Void) -> Self {
|
||||||
|
if onPointerLeave == nil { onPointerLeave = [] }
|
||||||
|
onPointerLeave?.append(handler)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func onClick(_ handler: @escaping (inout UIPointerEvent) -> Void) -> Self {
|
||||||
|
if onClick == nil { onClick = [] }
|
||||||
|
onClick?.append(handler)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func collectDrawCommands(offset: Vec2d = .zero) -> [UIDrawCommand] {
|
||||||
|
needsDisplay = false
|
||||||
|
guard let children = children else { return [] }
|
||||||
|
var out: [UIDrawCommand] = []
|
||||||
|
for child in children {
|
||||||
|
out.append(contentsOf: child.draw(offset: offset))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
open func draw(offset: Vec2d = .zero) -> [UIDrawCommand] {
|
||||||
|
var out: [UIDrawCommand] = drawBackground(offset)
|
||||||
|
out += collectDrawCommands(offset: offset)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
public func drawBackground(_ offset: Vec2d) -> [UIDrawCommand] {
|
||||||
|
var out: [UIDrawCommand] = []
|
||||||
|
let style = presentationStyle
|
||||||
|
let background = style[BackgroundColorStyle.self]
|
||||||
|
let borderWidth = style[BorderWidthStyle.self]
|
||||||
|
let borderColor = style[BorderColorStyle.self]
|
||||||
|
|
||||||
|
if background != .transparent {
|
||||||
|
out.append(.fillRect(rect: frame.offset(by: offset).inset(by: borderWidth / 2), color: background, cornerRadius: style[BorderRadiusStyle.self]))
|
||||||
|
}
|
||||||
|
|
||||||
|
if borderWidth > 0 && borderColor != .transparent {
|
||||||
|
out.append(.strokeRect(rect: frame.offset(by: offset), color: borderColor, thickness: borderWidth, cornerRadius: style[BorderRadiusStyle.self]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
open func preferredSize(_ maxWidth: Double? = nil, _ maxHeight: Double? = nil) -> Vec2d {
|
||||||
|
var maxSize = 0.0
|
||||||
|
var out = children?.reduce(.zero) {
|
||||||
|
let size = $1.preferredSize(maxWidth, maxHeight)
|
||||||
|
maxSize = max(maxSize, size.x)
|
||||||
|
return $0 + [0, size.y]
|
||||||
|
} ?? Vec2d()
|
||||||
|
|
||||||
|
out.x = maxSize
|
||||||
|
|
||||||
|
if case .absolute(let value) = presentationStyle[WidthStyle.self] {
|
||||||
|
out.x = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .absolute(let value) = presentationStyle[HeightStyle.self] {
|
||||||
|
out.y = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
open func layoutChildren() {
|
||||||
|
guard let children = children else { return }
|
||||||
|
let contentArea = contentFrame(forResolvedStyle: presentationStyle)
|
||||||
|
var cursor = 0.0
|
||||||
|
for child in children {
|
||||||
|
child.frame.origin.x = contentArea.origin.x + child.presentationStyle[MarginLeftStyle.self]
|
||||||
|
child.frame.origin.y = contentArea.origin.y + cursor + child.presentationStyle[MarginTopStyle.self]
|
||||||
|
child.frame.size.x = resolveLayoutSize(child, contentArea)
|
||||||
|
child.frame.size.y = resolveLayoutSize(child, contentArea, vertical: true)
|
||||||
|
|
||||||
|
cursor += child.frame.height
|
||||||
|
|
||||||
|
child.layoutChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resolveLayoutSize(_ child: Element, _ contentArea: Rect<Double>, vertical: Bool = false) -> Double {
|
||||||
|
switch vertical ? child.presentationStyle[HeightStyle.self] : child.presentationStyle[WidthStyle.self] {
|
||||||
|
case .absolute(let value):
|
||||||
|
return value
|
||||||
|
case .relative(let percent):
|
||||||
|
return (vertical ? contentArea.size.y : contentArea.size.x) * percent
|
||||||
|
case .auto:
|
||||||
|
let size = child.preferredSize()
|
||||||
|
return vertical ? size.y : size.x
|
||||||
|
case .grow(let weight, let from):
|
||||||
|
return from
|
||||||
|
case .shrink(let weight, let from):
|
||||||
|
return from
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open func hitTest(_ point: Vec2d) -> Element? {
|
||||||
|
// Default: check self frame, then recurse children (front-to-back)
|
||||||
|
guard frame.contains(point) else { return nil }
|
||||||
|
|
||||||
|
if let children = children?.reversed() { // z-order: last drawn = top
|
||||||
|
for child in children {
|
||||||
|
if let hit = child.hitTest(point) {
|
||||||
|
return hit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self // leaf or background hit
|
||||||
|
}
|
||||||
|
|
||||||
|
open func handleEvent(pointerEvent event: inout UIPointerEvent) {
|
||||||
|
switch event.type {
|
||||||
|
case .down:
|
||||||
|
if onPointerDown != nil {
|
||||||
|
for handler in onPointerDown! {
|
||||||
|
handler(&event)
|
||||||
|
if event.canceled { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .up:
|
||||||
|
if onPointerUp != nil {
|
||||||
|
for handler in onPointerUp! {
|
||||||
|
handler(&event)
|
||||||
|
if event.canceled { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .move:
|
||||||
|
if onPointerMove != nil {
|
||||||
|
for handler in onPointerMove! {
|
||||||
|
handler(&event)
|
||||||
|
if event.canceled { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .enter:
|
||||||
|
if onPointerEnter != nil {
|
||||||
|
for handler in onPointerEnter! {
|
||||||
|
handler(&event)
|
||||||
|
if event.canceled { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .leave:
|
||||||
|
if onPointerLeave != nil {
|
||||||
|
for handler in onPointerLeave! {
|
||||||
|
handler(&event)
|
||||||
|
if event.canceled { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .click:
|
||||||
|
if onClick != nil {
|
||||||
|
for handler in onClick! {
|
||||||
|
handler(&event)
|
||||||
|
if event.canceled { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
open func handleEvent(wheelEvent event: inout UIWheelEvent) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public func append(_ elements: Element...) {
|
||||||
|
append(elements)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func append(_ elements: [Element]) {
|
||||||
|
guard var children = children else { return }
|
||||||
|
for el in elements {
|
||||||
|
el.parent = self
|
||||||
|
el.context = context
|
||||||
|
children.append(el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func remove() {
|
||||||
|
parent?.children?.removeAll { $0.id == self.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func style<T: StyleKey>(_ type: T.Type, _ value: T.Value) -> Self {
|
||||||
|
style[type] = value
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func styles(@StyleBuilder _ properties: () -> [any StylePropertyType]) -> Self {
|
||||||
|
for prop in properties() {
|
||||||
|
prop.write(into: style)
|
||||||
|
}
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func group(_ name: String) -> Self {
|
||||||
|
groups.insert(name)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func groups(_ names: String...) -> Self {
|
||||||
|
groups(names)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func groups(_ names: [String]) -> Self {
|
||||||
|
groups.formUnion(names)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func animate(duration: Double, easing: EasingFunction = .linear, @StyleBuilder from: () -> [any StylePropertyType], @StyleBuilder to: () -> [any StylePropertyType]) {
|
||||||
|
context!.timeline.add(Animation(context!.timeline, self, from: from, to: to, easing: easing, duration: duration))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func dump(_ indent: Int = 0) {
|
||||||
|
if let children = children {
|
||||||
|
print("\(String(repeating: " ", count: indent * 4))\(Self.self) (frame: \(frame) {")
|
||||||
|
for el in children {
|
||||||
|
el.dump(indent + 1)
|
||||||
|
}
|
||||||
|
print("\(String(repeating: " ", count: indent * 4))}")
|
||||||
|
} else {
|
||||||
|
print("\(String(repeating: " ", count: indent * 4))\(Self.self)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Stack: Element {
|
||||||
|
public enum Direction: Sendable, Codable {
|
||||||
|
case vertical
|
||||||
|
case horizontal
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct GapStyle: StyleKey {
|
||||||
|
public typealias Value = Double
|
||||||
|
public static let defaultValue: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DirectionStyle: StyleKey {
|
||||||
|
public typealias Value = Direction
|
||||||
|
public static let defaultValue = Direction.vertical
|
||||||
|
}
|
||||||
|
|
||||||
|
public var direction: Direction {
|
||||||
|
get { style[DirectionStyle.self] }
|
||||||
|
set { style[DirectionStyle.self] = newValue }
|
||||||
|
}
|
||||||
|
public var alignment: Alignment {
|
||||||
|
get { style[AlignmentStyle.self] }
|
||||||
|
set { style[AlignmentStyle.self] = newValue }
|
||||||
|
}
|
||||||
|
public var gap: Double {
|
||||||
|
get { style[GapStyle.self] }
|
||||||
|
set { style[GapStyle.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(direction: Direction = .vertical, @UIBuilder children: () -> [Element]) {
|
||||||
|
self.init(direction: direction, children: children())
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(direction: Direction = .vertical, children: [Element] = []) {
|
||||||
|
super.init(children: children)
|
||||||
|
self.direction = direction
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func preferredSize(_ maxWidth: Double? = nil, _ maxHeight: Double? = nil) -> Vec2d {
|
||||||
|
var maxSize = 0.0
|
||||||
|
var out = children?.reduce(.zero) {
|
||||||
|
let size = $1.preferredSize(maxWidth, maxHeight)
|
||||||
|
maxSize = max(direction == .vertical ? size.x : size.y, maxSize)
|
||||||
|
return $0 + (direction == .vertical ? [0, size.y] : [size.x, 0])
|
||||||
|
} ?? Vec2d()
|
||||||
|
|
||||||
|
if direction == .vertical {
|
||||||
|
out.x = maxSize
|
||||||
|
} else {
|
||||||
|
out.y = maxSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .absolute(let value) = presentationStyle[WidthStyle.self] {
|
||||||
|
out.x = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if case .absolute(let value) = presentationStyle[HeightStyle.self] {
|
||||||
|
out.y = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func layoutChildren() {
|
||||||
|
if let children = children {
|
||||||
|
let contentArea = contentFrame(forResolvedStyle: presentationStyle)
|
||||||
|
var totalGrow: Double = 0
|
||||||
|
var usedSpace: Double = 0
|
||||||
|
var flexFound = false
|
||||||
|
|
||||||
|
let direction = direction
|
||||||
|
|
||||||
|
let axis = direction == .vertical ? contentArea.origin.y : contentArea.origin.x
|
||||||
|
let axisSize = direction == .vertical ? contentArea.size.y : contentArea.size.x
|
||||||
|
|
||||||
|
for child in children {
|
||||||
|
let axisValue = direction == .vertical ? child.presentationStyle[HeightStyle.self] : child.presentationStyle[WidthStyle.self]
|
||||||
|
switch axisValue {
|
||||||
|
case .absolute(let value):
|
||||||
|
usedSpace += value
|
||||||
|
case .relative(let percent):
|
||||||
|
usedSpace += axisSize * percent
|
||||||
|
case .auto:
|
||||||
|
let size = child.preferredSize()
|
||||||
|
usedSpace += direction == .vertical ? size.y : size.x
|
||||||
|
case .grow(let weight, let from):
|
||||||
|
flexFound = true
|
||||||
|
totalGrow += weight
|
||||||
|
case .shrink(let weight, let from):
|
||||||
|
flexFound = true
|
||||||
|
totalGrow += weight
|
||||||
|
}
|
||||||
|
|
||||||
|
usedSpace += direction == .vertical ?
|
||||||
|
child.presentationStyle[MarginLeftStyle.self] + child.presentationStyle[MarginRightStyle.self] :
|
||||||
|
child.presentationStyle[MarginTopStyle.self] + child.presentationStyle[MarginBottomStyle.self]
|
||||||
|
}
|
||||||
|
|
||||||
|
let gap = gap
|
||||||
|
|
||||||
|
usedSpace += gap * Double(max(0, children.count - 1))
|
||||||
|
let growBasis = totalGrow > 0 ? (max(0, axisSize - usedSpace) / totalGrow) : 0
|
||||||
|
|
||||||
|
let totalWidth = flexFound ? axisSize : usedSpace
|
||||||
|
|
||||||
|
var cursor: Double = axis + (alignment == .center ? (axisSize - totalWidth) / 2 : alignment == .trailing ? axisSize - totalWidth : 0)
|
||||||
|
for child in children {
|
||||||
|
if direction == .vertical {
|
||||||
|
child.frame.origin.y = cursor
|
||||||
|
child.frame.origin.x = contentArea.origin.x
|
||||||
|
child.frame.size.x = contentArea.size.x
|
||||||
|
} else {
|
||||||
|
child.frame.origin.x = cursor
|
||||||
|
child.frame.origin.y = contentArea.origin.y
|
||||||
|
child.frame.size.y = contentArea.size.y
|
||||||
|
}
|
||||||
|
|
||||||
|
let axisValue = direction == .vertical ? child.presentationStyle[HeightStyle.self] : child.presentationStyle[WidthStyle.self]
|
||||||
|
switch axisValue {
|
||||||
|
case .absolute(let value):
|
||||||
|
cursor += value + gap
|
||||||
|
if direction == .vertical {
|
||||||
|
child.frame.size.y = value
|
||||||
|
} else {
|
||||||
|
child.frame.size.x = value
|
||||||
|
}
|
||||||
|
case .relative(let percent):
|
||||||
|
cursor += axisSize * percent + gap
|
||||||
|
if direction == .vertical {
|
||||||
|
child.frame.size.y = axisSize * percent
|
||||||
|
} else {
|
||||||
|
child.frame.size.x = axisSize * percent
|
||||||
|
}
|
||||||
|
case .auto:
|
||||||
|
let size = child.preferredSize()
|
||||||
|
cursor += direction == .vertical ? size.y : size.x
|
||||||
|
if direction == .vertical {
|
||||||
|
child.frame.size.y = size.y
|
||||||
|
} else {
|
||||||
|
child.frame.size.x = size.x
|
||||||
|
}
|
||||||
|
case .grow(let weight, let from):
|
||||||
|
cursor += weight * growBasis
|
||||||
|
if direction == .vertical {
|
||||||
|
child.frame.size.y = weight * growBasis
|
||||||
|
} else {
|
||||||
|
child.frame.size.x = weight * growBasis
|
||||||
|
}
|
||||||
|
case .shrink(let weight, let from):
|
||||||
|
cursor += weight * growBasis
|
||||||
|
if direction == .vertical {
|
||||||
|
child.frame.size.y = weight * growBasis
|
||||||
|
} else {
|
||||||
|
child.frame.size.x = weight * growBasis
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for child in children {
|
||||||
|
child.layoutChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func gap(_ gap: Double) -> Self {
|
||||||
|
self.gap = gap
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func alignment(_ alignment: Alignment) -> Self {
|
||||||
|
self.alignment = alignment
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func VStack(@UIBuilder children: () -> [Element]) -> Stack {
|
||||||
|
Stack(direction: .vertical, children: children)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func VStack(children: [Element]) -> Stack {
|
||||||
|
Stack(direction: .vertical, children: children)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func HStack(@UIBuilder children: () -> [Element]) -> Stack {
|
||||||
|
Stack(direction: .horizontal, children: children)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func HStack(children: [Element]) -> Stack {
|
||||||
|
Stack(direction: .horizontal, children: children)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ZStack: Element {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Spacer: Element {
|
||||||
|
public init(width: LayoutSize? = nil, height: LayoutSize? = nil) {
|
||||||
|
super.init()
|
||||||
|
if let width = width {
|
||||||
|
style[WidthStyle.self] = width
|
||||||
|
}
|
||||||
|
if let height = height {
|
||||||
|
style[HeightStyle.self] = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ size: LayoutSize) {
|
||||||
|
super.init()
|
||||||
|
style[WidthStyle.self] = size
|
||||||
|
style[HeightStyle.self] = size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ScrollView: Element {
|
||||||
|
public var scrollPos = Vec2d() {
|
||||||
|
didSet {
|
||||||
|
setNeedsDisplay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public var contentHeight: Double = 0
|
||||||
|
|
||||||
|
public override func hitTest(_ point: Vec2d) -> Element? {
|
||||||
|
// Default: check self frame, then recurse children (front-to-back)
|
||||||
|
guard frame.contains(point) else { return nil }
|
||||||
|
|
||||||
|
let contentArea = contentFrame(forResolvedStyle: presentationStyle)
|
||||||
|
|
||||||
|
if let children = children?.reversed() { // z-order: last drawn = top
|
||||||
|
for child in children {
|
||||||
|
if let hit = child.hitTest(point - contentArea.origin + scrollPos) {
|
||||||
|
return hit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self // leaf or background hit
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func preferredSize(_ maxWidth: Double? = nil, _ maxHeight: Double? = nil) -> Vec2d {
|
||||||
|
super.preferredSize(maxWidth, maxHeight) + [14, 0]
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func handleEvent(wheelEvent event: inout UIWheelEvent) {
|
||||||
|
let newY = min(max(scrollPos.y - Double(event.scrollAmount.y), 0), contentHeight - frame.height)
|
||||||
|
if scrollPos.y == newY {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scrollPos.y = newY
|
||||||
|
setNeedsDisplay()
|
||||||
|
event.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
// public override func handleEvent(pointerEvent event: inout UIPointerEvent) {
|
||||||
|
// if event.type == .enter {
|
||||||
|
// context?.postCommands.append(.strokePath(path: .rect(contentFrame(forResolvedStyle: presentationStyle), cornerRadius: 0), color: .red, thickness: 1))
|
||||||
|
// } else if event.type == .leave {
|
||||||
|
// context?.postCommands.removeLast()
|
||||||
|
// }
|
||||||
|
// super.handleEvent(pointerEvent: &event)
|
||||||
|
// }
|
||||||
|
|
||||||
|
public override func layoutChildren() {
|
||||||
|
contentHeight = 0
|
||||||
|
guard let children = children else { return }
|
||||||
|
|
||||||
|
var contentArea = contentFrame(forResolvedStyle: presentationStyle)
|
||||||
|
contentArea.size.x -= 14
|
||||||
|
|
||||||
|
for child in children {
|
||||||
|
child.frame.origin.x = child.presentationStyle[MarginLeftStyle.self]
|
||||||
|
child.frame.origin.y = contentHeight + child.presentationStyle[MarginTopStyle.self]
|
||||||
|
child.frame.size.x = resolveLayoutSize(child, contentArea)
|
||||||
|
child.frame.size.y = resolveLayoutSize(child, contentArea, vertical: true)
|
||||||
|
|
||||||
|
contentHeight += child.frame.height
|
||||||
|
|
||||||
|
child.layoutChildren()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func draw(offset: Vec2d) -> [UIDrawCommand] {
|
||||||
|
var cmds: [UIDrawCommand] = drawBackground(offset)
|
||||||
|
|
||||||
|
let contentArea = contentFrame(forResolvedStyle: presentationStyle)
|
||||||
|
|
||||||
|
cmds.append(.clip(rect: contentArea.offset(by: offset)))
|
||||||
|
|
||||||
|
cmds += collectDrawCommands(offset: contentArea.origin + offset - scrollPos)
|
||||||
|
|
||||||
|
if contentHeight > contentArea.height {
|
||||||
|
let thumbHeight = max(16, contentArea.height * contentArea.height / contentHeight)
|
||||||
|
|
||||||
|
let scrollRatio = scrollPos.y / (contentHeight - contentArea.height)
|
||||||
|
|
||||||
|
let maxThumbY = contentArea.height - thumbHeight
|
||||||
|
let thumbY = contentArea.top + maxThumbY * scrollRatio
|
||||||
|
|
||||||
|
cmds.append(.fillRect(rect: Rect(origin: Vec2d(contentArea.right - 11, thumbY) + offset, size: Vec2d(8, thumbHeight)), color: Color(100, 100, 100), cornerRadius: 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
cmds.append(.popClip)
|
||||||
|
|
||||||
|
return cmds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Text: Element {
|
||||||
|
public var text: String {
|
||||||
|
get { style[TextStyle.self] }
|
||||||
|
set { style[TextStyle.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ text: String = "") {
|
||||||
|
super.init()
|
||||||
|
self.text = text
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func preferredSize(_ maxWidth: Double? = nil, _ maxHeight: Double? = nil) -> Vec2d {
|
||||||
|
context!.textProvider.measure(text: text, maxWidth: maxWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func draw(offset: Vec2d) -> [UIDrawCommand] {
|
||||||
|
let style = presentationStyle
|
||||||
|
return [.text(rect: frame.offset(by: offset), text: text, color: style[TextColorStyle.self], fontSize: 32)]
|
||||||
|
}
|
||||||
|
|
||||||
|
public override func dump(_ indent: Int = 0) {
|
||||||
|
print("\(String(repeating: " ", count: indent * 4))\(Self.self)(\"\(text)\") (frame: \(frame))")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TextView: Element {
|
||||||
|
|
||||||
|
}
|
||||||
222
Sources/ArtifactUI/Input.swift
Normal file
222
Sources/ArtifactUI/Input.swift
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ArtifactMath
|
||||||
|
import ArtifactPlatform
|
||||||
|
|
||||||
|
public class UIEvent {
|
||||||
|
public let timestamp: Double
|
||||||
|
public private(set) var consumed = false
|
||||||
|
public private(set) var canceled = false
|
||||||
|
public private(set) var target: Element
|
||||||
|
|
||||||
|
public init(timestamp: Double, target: Element) {
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.target = target
|
||||||
|
}
|
||||||
|
|
||||||
|
public func consume() {
|
||||||
|
consumed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cancel() {
|
||||||
|
canceled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UIPointerEvent: UIEvent {
|
||||||
|
public enum EventType {
|
||||||
|
case down, move, up
|
||||||
|
case enter, leave
|
||||||
|
case click
|
||||||
|
}
|
||||||
|
|
||||||
|
public let type: EventType
|
||||||
|
public let pointerId: Int
|
||||||
|
public let pointerType: PointerType
|
||||||
|
public let position: Vec2f
|
||||||
|
public let delta: Vec2f
|
||||||
|
public let pressure: Float
|
||||||
|
|
||||||
|
public init(timestamp: Double, target: Element, type: EventType, pointerId: Int, pointerType: PointerType, position: Vec2f, delta: Vec2f, pressure: Float) {
|
||||||
|
self.type = type
|
||||||
|
self.pointerId = pointerId
|
||||||
|
self.pointerType = pointerType
|
||||||
|
self.position = position
|
||||||
|
self.delta = delta
|
||||||
|
self.pressure = pressure
|
||||||
|
super.init(timestamp: timestamp, target: target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UIWheelEvent: UIEvent {
|
||||||
|
public let pointerId: Int
|
||||||
|
public let position: Vec2f
|
||||||
|
public let scrollAmount: Vec2f
|
||||||
|
|
||||||
|
public init(timestamp: Double, target: Element, pointerId: Int, position: Vec2f, scrollAmount: Vec2f) {
|
||||||
|
self.pointerId = pointerId
|
||||||
|
self.position = position
|
||||||
|
self.scrollAmount = scrollAmount
|
||||||
|
super.init(timestamp: timestamp, target: target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UIContext {
|
||||||
|
public func handlePointerDown(_ pointer: PointerState) {
|
||||||
|
handlePointerEvent(type: .down, pointer: pointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func handlePointerMove(_ pointer: PointerState) {
|
||||||
|
handlePointerEvent(type: .move, pointer: pointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func handlePointerUp(_ pointer: PointerState) {
|
||||||
|
handlePointerEvent(type: .up, pointer: pointer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePointerEvent(type: UIPointerEvent.EventType, pointer: PointerState) {
|
||||||
|
if let target = hitTest(Vec2(pointer.position)) {
|
||||||
|
if type == .down {
|
||||||
|
pointersDown[pointer.id] = (target: target, position: pointer.position, time: Date.timeIntervalSinceReferenceDate)
|
||||||
|
}
|
||||||
|
var ev = UIPointerEvent(
|
||||||
|
timestamp: Date.timeIntervalSinceReferenceDate,
|
||||||
|
target: target,
|
||||||
|
type: type,
|
||||||
|
pointerId: pointer.id,
|
||||||
|
pointerType: pointer.type,
|
||||||
|
position: pointer.position,
|
||||||
|
delta: pointer.delta,
|
||||||
|
pressure: pointer.pressure
|
||||||
|
)
|
||||||
|
var t: Element? = target
|
||||||
|
repeat {
|
||||||
|
t?.handleEvent(pointerEvent: &ev)
|
||||||
|
if ev.canceled { break }
|
||||||
|
t = t?.parent
|
||||||
|
} while t != nil && !ev.canceled
|
||||||
|
|
||||||
|
if type == .move || type == .down || pointer.type == .pen && type == .down {
|
||||||
|
updateHover(for: ev, newTarget: target)
|
||||||
|
}
|
||||||
|
|
||||||
|
if type == .up, let info = pointersDown.removeValue(forKey: pointer.id), info.target === target, (info.position - pointer.position).lengthSquared < 100 * 100 {
|
||||||
|
var ev = UIPointerEvent(
|
||||||
|
timestamp: Date.timeIntervalSinceReferenceDate,
|
||||||
|
target: info.target,
|
||||||
|
type: .click,
|
||||||
|
pointerId: pointer.id,
|
||||||
|
pointerType: pointer.type,
|
||||||
|
position: pointer.position,
|
||||||
|
delta: pointer.delta,
|
||||||
|
pressure: pointer.pressure
|
||||||
|
)
|
||||||
|
var t: Element? = info.target
|
||||||
|
repeat {
|
||||||
|
t?.handleEvent(pointerEvent: &ev)
|
||||||
|
if ev.canceled { break }
|
||||||
|
t = t?.parent
|
||||||
|
} while t != nil && !ev.canceled
|
||||||
|
}
|
||||||
|
} else if type == .move {
|
||||||
|
var ev = UIPointerEvent(
|
||||||
|
timestamp: Date.timeIntervalSinceReferenceDate,
|
||||||
|
target: self,
|
||||||
|
type: type,
|
||||||
|
pointerId: pointer.id,
|
||||||
|
pointerType: pointer.type,
|
||||||
|
position: pointer.position,
|
||||||
|
delta: pointer.delta,
|
||||||
|
pressure: pointer.pressure
|
||||||
|
)
|
||||||
|
updateHover(for: ev, newTarget: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateHover(for event: UIPointerEvent, newTarget: Element?) {
|
||||||
|
let id = event.pointerId
|
||||||
|
var oldTarget = hoverTarget[id]
|
||||||
|
var newTarget = newTarget
|
||||||
|
|
||||||
|
if oldTarget === newTarget { return }
|
||||||
|
|
||||||
|
if hoverTarget.contains(where: { $0.key != id && $0.value == oldTarget }) {
|
||||||
|
oldTarget = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if hoverTarget.contains(where: { $0.key != id && $0.value == newTarget }) {
|
||||||
|
newTarget = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var current: Element? = nil
|
||||||
|
|
||||||
|
// Leave old chain (leaf → root)
|
||||||
|
if let old = oldTarget {
|
||||||
|
var newEvent = UIPointerEvent(
|
||||||
|
timestamp: Date.timeIntervalSinceReferenceDate,
|
||||||
|
target: old,
|
||||||
|
type: .leave,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
pointerType: event.pointerType,
|
||||||
|
position: event.position,
|
||||||
|
delta: event.delta,
|
||||||
|
pressure: event.pressure
|
||||||
|
)
|
||||||
|
current = old
|
||||||
|
while let el = current {
|
||||||
|
el.handleEvent(pointerEvent: &newEvent)
|
||||||
|
if newEvent.canceled { break }
|
||||||
|
current = el.parent
|
||||||
|
// stop early if you reach a common ancestor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter new chain (root → leaf)
|
||||||
|
if let new = newTarget {
|
||||||
|
var newEvent = UIPointerEvent(
|
||||||
|
timestamp: Date.timeIntervalSinceReferenceDate,
|
||||||
|
target: new,
|
||||||
|
type: .enter,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
pointerType: event.pointerType,
|
||||||
|
position: event.position,
|
||||||
|
delta: event.delta,
|
||||||
|
pressure: event.pressure
|
||||||
|
)
|
||||||
|
current = new
|
||||||
|
var path: [Element] = []
|
||||||
|
while let el = current {
|
||||||
|
path.append(el)
|
||||||
|
current = el.parent
|
||||||
|
}
|
||||||
|
for el in path.reversed() {
|
||||||
|
el.handleEvent(pointerEvent: &newEvent)
|
||||||
|
if newEvent.canceled { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverTarget[id] = newTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
public func handleWheel(_ pointer: PointerState, delta: Vec2f) {
|
||||||
|
if let target = hitTest(Vec2(pointer.position)) {
|
||||||
|
var newEvent = UIWheelEvent(
|
||||||
|
timestamp: Date.timeIntervalSinceReferenceDate,
|
||||||
|
target: target,
|
||||||
|
pointerId: pointer.id,
|
||||||
|
position: pointer.position,
|
||||||
|
scrollAmount: delta * 5
|
||||||
|
)
|
||||||
|
var current: Element? = target
|
||||||
|
while let el = current {
|
||||||
|
el.handleEvent(wheelEvent: &newEvent)
|
||||||
|
if newEvent.canceled { break }
|
||||||
|
current = el.parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func attach(to window: any Window) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
86
Sources/ArtifactUI/STBTextProvider.swift
Normal file
86
Sources/ArtifactUI/STBTextProvider.swift
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
|
||||||
|
#if STB
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import ArtifactMath
|
||||||
|
import ArtifactColor
|
||||||
|
import stb_truetype
|
||||||
|
|
||||||
|
public class STBTextProvider: UITextProvider {
|
||||||
|
// FIXME: Add a font API
|
||||||
|
let font = try! Data(contentsOf: URL(filePath: "/Users/iboettcher/eclipse-workspace/ArtifactEngine/assets/fonts/Arial.ttf"))
|
||||||
|
public var pixels = [UInt8](repeating: 0, count: 1024 * 1024)
|
||||||
|
var chars = [stbtt_bakedchar](repeating: .init(), count: 256)
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
let atlas = font.withUnsafeBytes { ptr in
|
||||||
|
pixels.withUnsafeMutableBufferPointer { pixels in
|
||||||
|
chars.withUnsafeMutableBufferPointer { chars in
|
||||||
|
stbtt_BakeFontBitmap(ptr.baseAddress, 0, 32, pixels.baseAddress, 1024, 1024, Int32("\0".utf8CString[0]), 256, chars.baseAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pixels[0] = .max
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getQuadForChar(_ char: Character) -> Rect<Double> {
|
||||||
|
var x: Float = 0
|
||||||
|
var y: Float = 0
|
||||||
|
var quad = stbtt_aligned_quad()
|
||||||
|
chars.withUnsafeMutableBufferPointer { chars in
|
||||||
|
stbtt_GetBakedQuad(chars.baseAddress, 1024, 1024, Int32(char.asciiValue ?? "?".first!.asciiValue!), &x, &y, &quad, 1)
|
||||||
|
}
|
||||||
|
return Rect(origin: Vec2d(Double(x), Double(y)), size: Vec2(Double(quad.x1 - quad.x0), Double(quad.y1 - quad.y0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func getCharInfo(_ char: Character) -> stbtt_bakedchar {
|
||||||
|
return chars[Int(char.asciiValue ?? "?".first!.asciiValue!)]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func measure(text: String, maxWidth: Double?) -> Vec2d {
|
||||||
|
var out = Vec2d(0.0, 0.0)
|
||||||
|
var advance = Vec2d(0.0, 32.0)
|
||||||
|
for char in text {
|
||||||
|
let info = getCharInfo(char)
|
||||||
|
advance.x += Double(info.xadvance)
|
||||||
|
if let maxWidth = maxWidth, advance.x >= maxWidth {
|
||||||
|
out.x = maxWidth
|
||||||
|
advance.x = 0
|
||||||
|
advance.y += 32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.x = max(out.x, advance.x)
|
||||||
|
out.y = advance.y
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
public func drawText(into list: inout UIDrawList, rect: Rect<Double>, text: String, color: Color, fontName: String?, fontSize: Float, alignment: Alignment) {
|
||||||
|
var advance: Float = 0.0
|
||||||
|
for char in text {
|
||||||
|
let info = getCharInfo(char)
|
||||||
|
let cw = Float(info.x1 - info.x0)
|
||||||
|
let ch = Float(info.y1 - info.y0)
|
||||||
|
|
||||||
|
let uvLeft = Float(info.x0) / 1024.0
|
||||||
|
let uvRight = Float(info.x1) / 1024.0
|
||||||
|
let uvTop = Float(info.y0) / 1024.0
|
||||||
|
let uvBottom = Float(info.y1) / 1024.0
|
||||||
|
|
||||||
|
list.addQuad(
|
||||||
|
bounds: Rect(
|
||||||
|
origin: rect.origin + (Double(advance + info.xoff), Double(fontSize + info.yoff)),
|
||||||
|
size: Vec2d(Double(cw), Double(ch))
|
||||||
|
),
|
||||||
|
uv: Rect(
|
||||||
|
origin: Vec2d(Double(uvLeft), Double(uvTop)),
|
||||||
|
size: Vec2d(Double(uvRight - uvLeft), Double(uvBottom - uvTop))
|
||||||
|
),
|
||||||
|
color: color
|
||||||
|
)
|
||||||
|
advance += info.xadvance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
600
Sources/ArtifactUI/Style.swift
Normal file
600
Sources/ArtifactUI/Style.swift
Normal file
|
|
@ -0,0 +1,600 @@
|
||||||
|
|
||||||
|
import Observation
|
||||||
|
import ArtifactColor
|
||||||
|
|
||||||
|
public protocol StyleKey {
|
||||||
|
associatedtype Value: Codable, Equatable
|
||||||
|
static var defaultValue: Value { get }
|
||||||
|
static var codingKey: String { get }
|
||||||
|
static var affectsLayout: Bool { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StyleKey {
|
||||||
|
public static var codingKey: String { String(reflecting: Self.self) }
|
||||||
|
public static var affectsLayout: Bool { false }
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol CascadingStyleKey: StyleKey {}
|
||||||
|
|
||||||
|
public struct WidthStyle: StyleKey {
|
||||||
|
public static let codingKey = "width"
|
||||||
|
public typealias Value = LayoutSize
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: LayoutSize = .auto
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HeightStyle: StyleKey {
|
||||||
|
public static let codingKey = "height"
|
||||||
|
public typealias Value = LayoutSize
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: LayoutSize = .auto
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BackgroundColorStyle: CascadingStyleKey {
|
||||||
|
public static let codingKey = "backgroundColor"
|
||||||
|
public typealias Value = Color
|
||||||
|
public static let defaultValue: Color = .transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TextColorStyle: CascadingStyleKey {
|
||||||
|
public typealias Value = Color
|
||||||
|
public static let defaultValue: Color = .black
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BorderColorStyle: StyleKey {
|
||||||
|
public typealias Value = Color
|
||||||
|
public static let defaultValue: Color = .black
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BorderWidthStyle: StyleKey {
|
||||||
|
public typealias Value = Double
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BorderRadiusStyle: StyleKey {
|
||||||
|
public typealias Value = Double
|
||||||
|
public static let defaultValue: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MarginTopStyle: StyleKey {
|
||||||
|
public typealias Value = Double
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MarginBottomStyle: StyleKey {
|
||||||
|
public typealias Value = Double
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MarginLeftStyle: StyleKey {
|
||||||
|
public typealias Value = Double
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MarginRightStyle: StyleKey {
|
||||||
|
public typealias Value = Double
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PaddingTopStyle: StyleKey {
|
||||||
|
public typealias Value = Double
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PaddingBottomStyle: StyleKey {
|
||||||
|
public typealias Value = Double
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PaddingLeftStyle: StyleKey {
|
||||||
|
public typealias Value = Double
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PaddingRightStyle: StyleKey {
|
||||||
|
public typealias Value = Double
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: Double = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TextStyle: StyleKey {
|
||||||
|
public typealias Value = String
|
||||||
|
public static let defaultValue = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AlignmentStyle: StyleKey {
|
||||||
|
public typealias Value = Alignment
|
||||||
|
public static let affectsLayout: Bool = true
|
||||||
|
public static let defaultValue: Alignment = .leading
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
public class Style: Codable {
|
||||||
|
var storage: [String: AnyCodable] = [:]
|
||||||
|
|
||||||
|
// A change to a parent should not trigger a change in its children; instead, the change will be cascaded down later on.
|
||||||
|
@ObservationIgnored
|
||||||
|
weak var parent: Style?
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
|
weak var chain: Style?
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
|
public var onChange: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
enum CodingKeys: CodingKey {
|
||||||
|
case _storage
|
||||||
|
}
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public convenience init(@StyleBuilder _ properties: () -> [any StylePropertyType]) {
|
||||||
|
self.init(properties())
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(_ properties: any StylePropertyType...) {
|
||||||
|
self.init(properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ properties: [any StylePropertyType]) {
|
||||||
|
for prop in properties {
|
||||||
|
prop.write(into: self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<Key: StyleKey>(_ key: Key.Type, ifUnset: Bool = false) -> Key.Value {
|
||||||
|
get {
|
||||||
|
storage[key.codingKey]?.value as? Key.Value ?? (key is any CascadingStyleKey ? parent?[key] : nil) ?? chain?[key] ?? Key.defaultValue
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
if newValue == Key.defaultValue {
|
||||||
|
storage[key.codingKey] = nil
|
||||||
|
} else {
|
||||||
|
storage[key.codingKey] = AnyCodable(newValue)
|
||||||
|
}
|
||||||
|
onChange?(Key.affectsLayout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func contains<Key: StyleKey>(_ key: Key.Type) -> Bool {
|
||||||
|
storage[key.codingKey] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func withProperty<Key: StyleKey>(_ key: Key.Type, _ value: Key.Value) -> Style {
|
||||||
|
storage[key.codingKey] = AnyCodable(value)
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
public func merge(with other: Style) {
|
||||||
|
storage.merge(other.storage, uniquingKeysWith: { $1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
public func merged(with other: Style) -> Style {
|
||||||
|
let copy = Style()
|
||||||
|
copy.merge(with: self)
|
||||||
|
copy.merge(with: other)
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
|
public func rebase(onto other: Style) {
|
||||||
|
parent = other
|
||||||
|
}
|
||||||
|
|
||||||
|
public func chain(onto other: Style) {
|
||||||
|
chain = other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Specificity: Comparable, Equatable, Sendable {
|
||||||
|
public let inline: Int // 1000+
|
||||||
|
public let id: Int // 100+
|
||||||
|
public let group: Int // 10+
|
||||||
|
public let type: Int // 1+
|
||||||
|
|
||||||
|
public static let zero = Specificity(inline: 0, id: 0, group: 0, type: 0)
|
||||||
|
|
||||||
|
public static func inline(_ value: Int = 1) -> Specificity {
|
||||||
|
Specificity(inline: value, id: 0, group: 0, type: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convenience
|
||||||
|
public static var id: Specificity { Specificity(inline: 0, id: 1, group: 0, type: 0) }
|
||||||
|
public static var group: Specificity { Specificity(inline: 0, id: 0, group: 1, type: 0) }
|
||||||
|
public static var type: Specificity { Specificity(inline: 0, id: 0, group: 0, type: 1) }
|
||||||
|
|
||||||
|
public static func < (lhs: Specificity, rhs: Specificity) -> Bool {
|
||||||
|
(lhs.inline, lhs.id, lhs.group, lhs.type) < (rhs.inline, rhs.id, rhs.group, rhs.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func + (lhs: Specificity, rhs: Specificity) -> Specificity {
|
||||||
|
Specificity(inline: lhs.inline + rhs.inline, id: lhs.id + rhs.id, group: lhs.group + rhs.group, type: lhs.type + rhs.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol StyleSelector {
|
||||||
|
var specificity: Specificity { get }
|
||||||
|
func matches(_ element: Element) -> Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol ElementaryStyleSelector: StyleSelector {}
|
||||||
|
|
||||||
|
public class TypeSelector: ElementaryStyleSelector {
|
||||||
|
public let specificity: Specificity = .type
|
||||||
|
var type: String
|
||||||
|
|
||||||
|
public init(_ type: String) {
|
||||||
|
self.type = type
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ type: any ElementProtocol.Type) {
|
||||||
|
self.type = type.elementName
|
||||||
|
}
|
||||||
|
|
||||||
|
public func matches(_ element: Element) -> Bool {
|
||||||
|
element.type == type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GroupSelector: ElementaryStyleSelector {
|
||||||
|
public let specificity: Specificity = .group
|
||||||
|
var name: String
|
||||||
|
|
||||||
|
public init(_ name: String) {
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
public func matches(_ element: Element) -> Bool {
|
||||||
|
element.groups.contains(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChildSelector: StyleSelector {
|
||||||
|
public var specificity: Specificity {
|
||||||
|
parent.specificity + child.specificity
|
||||||
|
}
|
||||||
|
let parent: StyleSelector
|
||||||
|
let child: StyleSelector
|
||||||
|
|
||||||
|
public init(parent: StyleSelector, child: StyleSelector) {
|
||||||
|
self.parent = parent
|
||||||
|
self.child = child
|
||||||
|
}
|
||||||
|
|
||||||
|
public func matches(_ element: Element) -> Bool {
|
||||||
|
if element.parent != nil && parent.matches(element.parent!) {
|
||||||
|
child.matches(element)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AnySelector: StyleSelector {
|
||||||
|
public var specificity: Specificity {
|
||||||
|
selectors.reduce(.zero) { $0 + $1.specificity }
|
||||||
|
}
|
||||||
|
var selectors: [any StyleSelector] = []
|
||||||
|
|
||||||
|
public convenience init(_ selectors: any StyleSelector...) {
|
||||||
|
self.init(selectors)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ selectors: [any StyleSelector]) {
|
||||||
|
self.selectors = selectors
|
||||||
|
}
|
||||||
|
|
||||||
|
public func matches(_ element: Element) -> Bool {
|
||||||
|
for sel in selectors {
|
||||||
|
if sel.matches(element) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AllSelector: StyleSelector {
|
||||||
|
public var specificity: Specificity {
|
||||||
|
selectors.reduce(.zero) { max($0, $1.specificity) }
|
||||||
|
}
|
||||||
|
var selectors: [any StyleSelector] = []
|
||||||
|
|
||||||
|
public convenience init(_ selectors: any StyleSelector...) {
|
||||||
|
self.init(selectors)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ selectors: [any StyleSelector]) {
|
||||||
|
self.selectors = selectors
|
||||||
|
}
|
||||||
|
|
||||||
|
public func matches(_ element: Element) -> Bool {
|
||||||
|
for sel in selectors {
|
||||||
|
if !sel.matches(element) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RootSelector: StyleSelector {
|
||||||
|
public let specificity: Specificity = .zero
|
||||||
|
|
||||||
|
public func matches(_ element: Element) -> Bool {
|
||||||
|
element is UIContext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StyleSelector where Self == AllSelector /* FIXME: <- ??? */ {
|
||||||
|
public static var root: RootSelector { RootSelector() }
|
||||||
|
public static func any(_ selectors: StyleSelector...) -> AnySelector {
|
||||||
|
AnySelector(selectors)
|
||||||
|
}
|
||||||
|
public static func all(_ selectors: StyleSelector...) -> AllSelector {
|
||||||
|
AllSelector(selectors)
|
||||||
|
}
|
||||||
|
public static func group(_ name: String) -> GroupSelector {
|
||||||
|
GroupSelector(name)
|
||||||
|
}
|
||||||
|
public static func type(_ name: String) -> TypeSelector {
|
||||||
|
TypeSelector(name)
|
||||||
|
}
|
||||||
|
public static func type(_ type: any ElementProtocol.Type) -> TypeSelector {
|
||||||
|
TypeSelector(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func + (_ left: any StyleSelector, _ right: any StyleSelector) -> AllSelector {
|
||||||
|
if left is AllSelector {
|
||||||
|
(left as! AllSelector).selectors.append(right)
|
||||||
|
return left as! AllSelector
|
||||||
|
} else if right is AllSelector {
|
||||||
|
(right as! AllSelector).selectors.insert(left, at: 0)
|
||||||
|
return right as! AllSelector
|
||||||
|
} else {
|
||||||
|
return AllSelector(left, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func | (_ left: any StyleSelector, _ right: any StyleSelector) -> AnySelector {
|
||||||
|
if left is AnySelector {
|
||||||
|
(left as! AnySelector).selectors.append(right)
|
||||||
|
return left as! AnySelector
|
||||||
|
} else if right is AllSelector {
|
||||||
|
(right as! AnySelector).selectors.insert(left, at: 0)
|
||||||
|
return right as! AnySelector
|
||||||
|
} else {
|
||||||
|
return AnySelector(left, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func / (_ left: any StyleSelector, _ right: any StyleSelector) -> ChildSelector {
|
||||||
|
ChildSelector(parent: left, child: right)
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StyleRule {
|
||||||
|
var selector: any StyleSelector
|
||||||
|
var style: Style
|
||||||
|
var order: Int
|
||||||
|
var children: [StyleRule]?
|
||||||
|
|
||||||
|
public init(selector: any StyleSelector, style: Style) {
|
||||||
|
self.selector = selector
|
||||||
|
self.style = style
|
||||||
|
self.order = #line
|
||||||
|
}
|
||||||
|
|
||||||
|
public convenience init(_ selector: any StyleSelector, @StyleBuilder _ parts: () -> [any StyleRulePart]) {
|
||||||
|
let style = Style()
|
||||||
|
var children: [StyleRule] = []
|
||||||
|
for part in parts() {
|
||||||
|
if part is Style {
|
||||||
|
style.merge(with: part as! Style)
|
||||||
|
} else if part is any StylePropertyType {
|
||||||
|
(part as! any StylePropertyType).write(into: style)
|
||||||
|
} else if part is StyleRule {
|
||||||
|
let child = part as! StyleRule
|
||||||
|
child.selector = ChildSelector(parent: selector, child: child.selector)
|
||||||
|
children.append(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.init(selector: selector, style: style)
|
||||||
|
self.children = children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StyleSheet {
|
||||||
|
let rootStyle = Style()
|
||||||
|
var rules: [StyleRule] = []
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public init(@StyleBuilder rules: () -> [StyleRule]) {
|
||||||
|
self.rules = rules().flatMap {
|
||||||
|
if $0.selector is RootSelector {
|
||||||
|
rootStyle.merge(with: $0.style)
|
||||||
|
}
|
||||||
|
let out = [$0] + ($0.children ?? [])
|
||||||
|
$0.children = nil
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func merge(with other: StyleSheet) {
|
||||||
|
rootStyle.merge(with: other.rootStyle)
|
||||||
|
// TODO: Better merging
|
||||||
|
}
|
||||||
|
|
||||||
|
public func matchingRules(for element: Element) -> [StyleRule] {
|
||||||
|
rules.filter { $0.selector.matches(element) }
|
||||||
|
.sorted {
|
||||||
|
if $0.selector.specificity != $1.selector.specificity { return $0.selector.specificity > $1.selector.specificity }
|
||||||
|
return $0.order < $1.order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resolveStyle(for element: Element) -> Style {
|
||||||
|
let final = Style()
|
||||||
|
final.rebase(onto: rootStyle)
|
||||||
|
|
||||||
|
let matching = matchingRules(for: element)
|
||||||
|
for rule in matching {
|
||||||
|
final.merge(with: rule.style)
|
||||||
|
}
|
||||||
|
|
||||||
|
final.merge(with: element.style)
|
||||||
|
|
||||||
|
return final
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol StylePropertyType: StyleRulePart {
|
||||||
|
associatedtype Key: StyleKey
|
||||||
|
var codingKey: String { get }
|
||||||
|
var affectsLayout: Bool { get }
|
||||||
|
func write(into style: Style)
|
||||||
|
func read(from style: Style) -> Self
|
||||||
|
func lerp(to: any StylePropertyType, progress: Double) -> any StylePropertyType
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct StyleProperty<Key: StyleKey>: StylePropertyType, CustomDebugStringConvertible {
|
||||||
|
public let value: Key.Value
|
||||||
|
public var codingKey: String { Key.codingKey }
|
||||||
|
public var affectsLayout: Bool { Key.affectsLayout }
|
||||||
|
public var debugDescription: String { "StyleProperty<\(Key.codingKey)>: \(value)" }
|
||||||
|
|
||||||
|
public func write(into style: Style) {
|
||||||
|
style[Key.self] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
public func read(from style: Style) -> Self {
|
||||||
|
Self(value: style[Key.self])
|
||||||
|
}
|
||||||
|
|
||||||
|
public func lerp(to: any StylePropertyType, progress: Double) -> any StylePropertyType {
|
||||||
|
if let to = to as? Self {
|
||||||
|
if let a = value as? any BinaryFloatingPoint, let b = to.value as? any BinaryFloatingPoint {
|
||||||
|
Self(value: lerpFloat(from: a, to: b, progress: progress) as! Key.Value)
|
||||||
|
} else if let a = value as? any BinaryInteger, let b = to.value as? any BinaryInteger {
|
||||||
|
Self(value: lerpInt(from: a, to: b, progress: progress) as! Key.Value)
|
||||||
|
} else if let a = value as? LayoutSize, let b = to.value as? LayoutSize {
|
||||||
|
Self(value: lerpSize(from: a, to: b, progress: progress) as! Key.Value)
|
||||||
|
} else if let a = value as? Color, let b = to.value as? Color {
|
||||||
|
Self(value: lerpColor(from: a, to: b, progress: progress) as! Key.Value)
|
||||||
|
} else {
|
||||||
|
Self(value: progress > 0.5 ? to.value : value)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lerpFloat(from: any BinaryFloatingPoint, to: any BinaryFloatingPoint, progress: Double) -> Double {
|
||||||
|
Double(from) + (Double(to) - Double(from)) * progress
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lerpInt(from: any BinaryInteger, to: any BinaryInteger, progress: Double) -> Double {
|
||||||
|
Double(from) + (Double(to) - Double(from)) * progress
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lerpColor(from: Color, to: Color, progress: Double) -> Color {
|
||||||
|
Color.lerp(from: from, to: to, progress: progress)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lerpSize(from: LayoutSize, to: LayoutSize, progress: Double) -> LayoutSize {
|
||||||
|
switch (from, to) {
|
||||||
|
case (.absolute(let a), .absolute(let b)):
|
||||||
|
.absolute(a + (b - a) * progress)
|
||||||
|
case (.relative(let a), .relative(let b)):
|
||||||
|
.relative(a + (b - a) * progress)
|
||||||
|
default:
|
||||||
|
progress > 0.5 ? to : from
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension StyleKey {
|
||||||
|
public static func property(_ value: Value) -> any StylePropertyType {
|
||||||
|
StyleProperty<Self>(value: value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol StyleRulePart {}
|
||||||
|
|
||||||
|
extension Style: StyleRulePart {}
|
||||||
|
extension StyleRule: StyleRulePart {}
|
||||||
|
|
||||||
|
@resultBuilder
|
||||||
|
public struct StyleBuilder {
|
||||||
|
public static func buildBlock(_ components: StyleRule...) -> [StyleRule] {
|
||||||
|
components
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildBlock(_ components: any StylePropertyType...) -> [any StylePropertyType] {
|
||||||
|
components
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildBlock(_ components: any StyleRulePart...) -> [any StyleRulePart] {
|
||||||
|
components
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildEither(first component: StyleRule) -> StyleRule {
|
||||||
|
component
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildEither(second component: StyleRule) -> StyleRule {
|
||||||
|
component
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildEither(first component: Style) -> Style {
|
||||||
|
component
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildEither(second component: Style) -> Style {
|
||||||
|
component
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildArray(_ components: [Style]) -> Style {
|
||||||
|
let out = Style()
|
||||||
|
for component in components {
|
||||||
|
out.merge(with: component)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func buildOptional(_ component: Style?) -> Style {
|
||||||
|
component ?? Style()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func foo() {
|
||||||
|
let sheet = StyleSheet {
|
||||||
|
StyleRule(.all(.any(.group("Test"), .group("Test2")))) {
|
||||||
|
BackgroundColorStyle.property(.black)
|
||||||
|
|
||||||
|
Style {
|
||||||
|
BorderRadiusStyle.property(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
StyleRule(.group("aaa")) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyleRule(.group("test") + .type("Thing") / .group("e")) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
StyleRule(.group("Test")) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Sources/ArtifactUI/Text.swift
Normal file
8
Sources/ArtifactUI/Text.swift
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
|
||||||
|
import ArtifactMath
|
||||||
|
import ArtifactColor
|
||||||
|
|
||||||
|
public protocol UITextProvider {
|
||||||
|
func measure(text: String, maxWidth: Double?) -> Vec2d
|
||||||
|
func drawText(into list: inout UIDrawList, rect: Rect<Double>, text: String, color: Color, fontName: String?, fontSize: Float, alignment: Alignment)
|
||||||
|
}
|
||||||
163
Sources/ArtifactUI/Types.swift
Normal file
163
Sources/ArtifactUI/Types.swift
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum Alignment: Sendable, Codable {
|
||||||
|
case leading
|
||||||
|
case center
|
||||||
|
case trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct LayoutConstraints {
|
||||||
|
public var minSize: (width: LayoutSize, height: LayoutSize) = (width: .absolute(0), height: .absolute(0))
|
||||||
|
public var maxSize: (width: LayoutSize, height: LayoutSize) = (width: .auto, height: .auto)
|
||||||
|
|
||||||
|
public static var unbounded: LayoutConstraints { LayoutConstraints() }
|
||||||
|
}
|
||||||
|
|
||||||
|
postfix operator %
|
||||||
|
postfix operator +
|
||||||
|
postfix operator -
|
||||||
|
|
||||||
|
public enum LayoutSize: Sendable, Codable, Equatable, ExpressibleByFloatLiteral, ExpressibleByIntegerLiteral {
|
||||||
|
public typealias IntegerLiteralType = Int
|
||||||
|
public typealias FloatLiteralType = Double
|
||||||
|
|
||||||
|
case absolute(_ value: Double)
|
||||||
|
case relative(_ percent: Double)
|
||||||
|
case auto
|
||||||
|
case grow(_ weight: Double = 1, from: Double = 0)
|
||||||
|
case shrink(_ weight: Double = 1, from: Double = 0)
|
||||||
|
|
||||||
|
public init(floatLiteral value: Double) {
|
||||||
|
self = .absolute(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(integerLiteral value: Int) {
|
||||||
|
self = .absolute(Double(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public postfix func % (_ value: Double) -> LayoutSize {
|
||||||
|
.relative(value / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
public postfix func + (_ value: Double) -> LayoutSize {
|
||||||
|
.grow(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public postfix func - (_ value: Double) -> LayoutSize {
|
||||||
|
.shrink(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Insets: ExpressibleByArrayLiteral, ExpressibleByFloatLiteral {
|
||||||
|
public typealias ArrayLiteralElement = Double
|
||||||
|
public typealias FloatLiteralType = Double
|
||||||
|
|
||||||
|
public var top: Double
|
||||||
|
public var bottom: Double
|
||||||
|
public var left: Double
|
||||||
|
public var right: Double
|
||||||
|
|
||||||
|
public init(arrayLiteral elements: Double...) {
|
||||||
|
switch elements.count {
|
||||||
|
case 0:
|
||||||
|
top = 0
|
||||||
|
bottom = 0
|
||||||
|
left = 0
|
||||||
|
right = 0
|
||||||
|
case 1:
|
||||||
|
top = elements[0]
|
||||||
|
bottom = elements[0]
|
||||||
|
left = elements[0]
|
||||||
|
right = elements[0]
|
||||||
|
case 2:
|
||||||
|
top = elements[0]
|
||||||
|
bottom = elements[0]
|
||||||
|
left = elements[1]
|
||||||
|
right = elements[1]
|
||||||
|
case 3:
|
||||||
|
top = elements[0]
|
||||||
|
bottom = elements[1]
|
||||||
|
left = elements[2]
|
||||||
|
right = elements[2]
|
||||||
|
default:
|
||||||
|
top = elements[0]
|
||||||
|
bottom = elements[1]
|
||||||
|
left = elements[2]
|
||||||
|
right = elements[3]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(floatLiteral value: Double) {
|
||||||
|
top = value
|
||||||
|
bottom = value
|
||||||
|
left = value
|
||||||
|
right = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct None { }
|
||||||
|
|
||||||
|
struct AnyCodable: Codable {
|
||||||
|
public let value: Any
|
||||||
|
|
||||||
|
public init(_ value: Any) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Decoding
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
|
||||||
|
if container.decodeNil() {
|
||||||
|
self.value = None()
|
||||||
|
} else if let bool = try? container.decode(Bool.self) {
|
||||||
|
self.value = bool
|
||||||
|
} else if let int = try? container.decode(Int.self) {
|
||||||
|
self.value = int
|
||||||
|
} else if let double = try? container.decode(Double.self) {
|
||||||
|
self.value = double
|
||||||
|
} else if let string = try? container.decode(String.self) {
|
||||||
|
self.value = string
|
||||||
|
} else if let array = try? container.decode([AnyCodable].self) {
|
||||||
|
self.value = array.map { $0.value }
|
||||||
|
} else if let dict = try? container.decode([String: AnyCodable].self) {
|
||||||
|
self.value = dict.mapValues { $0.value }
|
||||||
|
} else {
|
||||||
|
throw DecodingError.dataCorruptedError(
|
||||||
|
in: container,
|
||||||
|
debugDescription: "AnyCodable cannot decode value"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Encoding
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
|
||||||
|
switch value {
|
||||||
|
case is None:
|
||||||
|
try container.encodeNil()
|
||||||
|
case let bool as Bool:
|
||||||
|
try container.encode(bool)
|
||||||
|
case let int as Int:
|
||||||
|
try container.encode(int)
|
||||||
|
case let double as Double:
|
||||||
|
try container.encode(double)
|
||||||
|
case let string as String:
|
||||||
|
try container.encode(string)
|
||||||
|
case let array as [Any]:
|
||||||
|
try container.encode(array.map(AnyCodable.init))
|
||||||
|
case let dict as [String: Any]:
|
||||||
|
try container.encode(dict.mapValues(AnyCodable.init))
|
||||||
|
case let codable as Encodable:
|
||||||
|
try codable.encode(to: encoder)
|
||||||
|
default:
|
||||||
|
let context = EncodingError.Context(
|
||||||
|
codingPath: container.codingPath,
|
||||||
|
debugDescription: "AnyCodable cannot encode value: \(type(of: value))"
|
||||||
|
)
|
||||||
|
throw EncodingError.invalidValue(value, context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
Sources/stb_truetype/stb_truetype.c
Normal file
3
Sources/stb_truetype/stb_truetype.c
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
|
||||||
|
#define STB_TRUETYPE_IMPLEMENTATION
|
||||||
|
#include "stb_truetype.h"
|
||||||
5079
Sources/stb_truetype/stb_truetype.h
Normal file
5079
Sources/stb_truetype/stb_truetype.h
Normal file
File diff suppressed because it is too large
Load diff
12
Tests/ArtifactUITests/ArtifactUITests.swift
Normal file
12
Tests/ArtifactUITests/ArtifactUITests.swift
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import XCTest
|
||||||
|
@testable import ArtifactUI
|
||||||
|
|
||||||
|
final class ArtifactUITests: XCTestCase {
|
||||||
|
func testExample() throws {
|
||||||
|
// XCTest Documentation
|
||||||
|
// https://developer.apple.com/documentation/xctest
|
||||||
|
|
||||||
|
// Defining Test Cases and Test Methods
|
||||||
|
// https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue