Initial Commit

This commit is contained in:
Signal 2026-06-22 16:11:41 -04:00
commit 9f32b787c7
15 changed files with 7823 additions and 0 deletions

View 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()
}
}

View 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 }
}
}

View file

@ -0,0 +1,7 @@
import Foundation
public enum UIChange: Codable {
case insert(path: [UUID], index: Int)
case remove(path: [UUID])
}

View 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: &current)
cursor = end
case .cubic(let c1, let c2, let end):
flattenCubic(start: cursor, c1: c1, c2: c2, end: end, tolerance: tolerance, into: &current)
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))
}
}

View 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 {
}

View 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) {
}
}

View 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

View 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")) {
}
}
}

View 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)
}

View 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)
}
}
}

View file

@ -0,0 +1,3 @@
#define STB_TRUETYPE_IMPLEMENTATION
#include "stb_truetype.h"

File diff suppressed because it is too large Load diff