Foundation implementation

This commit is contained in:
Brendan Szymanski 2026-07-23 19:36:28 -04:00
commit 145d0a710d
27 changed files with 739 additions and 0 deletions

43
.gitignore vendored Normal file
View file

@ -0,0 +1,43 @@
# ==============================================================================
# OS files
# ==============================================================================
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
.AppleDouble
.LSOverride
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
Desktop.ini
$RECYCLE.BIN/
# ==============================================================================
# Editors & IDEs
# ==============================================================================
.vscode/
.idea/
*.iml
*.swp
*.swo
*~
# ==============================================================================
# Swift / SwiftPM
# ==============================================================================
# Build artifacts
.build/
DerivedData/
# SwiftPM workspace state (generated, editor-specific)
.swiftpm/
Package.resolved
# Xcode project files (generated from Package.swift)
*.xcodeproj
*.xcworkspace
xcuserdata/

42
Package.swift Normal file
View file

@ -0,0 +1,42 @@
// swift-tools-version: 6.2
import PackageDescription
let swiftSettings: [SwiftSetting] = [
.enableExperimentalFeature("StrictConcurrency=complete"),
.defaultIsolation(MainActor.self),
]
let package = Package(
name: "portico",
platforms: [.macOS(.v14)],
dependencies: [
.package(url: "https://git.bscubed.dev/gtk-swift/gtk-swift.git", branch: "main"),
],
targets: [
.target(
name: "Portico",
dependencies: [
.product(name: "Adw", package: "gtk-swift"),
.product(name: "Gtk", package: "gtk-swift"),
.product(name: "Gio", package: "gtk-swift"),
.product(name: "GLib", package: "gtk-swift"),
.product(name: "GObject", package: "gtk-swift"),
.product(name: "Gdk", package: "gtk-swift"),
],
swiftSettings: swiftSettings
),
.executableTarget(
name: "Example",
dependencies: ["Portico"],
swiftSettings: swiftSettings
),
.testTarget(
name: "PorticoTests",
dependencies: [
"Portico",
.product(name: "Gtk", package: "gtk-swift"),
],
swiftSettings: swiftSettings
),
]
)

View file

@ -0,0 +1,17 @@
import Portico
@main
struct ExampleApp: App {
var applicationId: String? { "dev.bscubed.PorticoExample" }
var body: some Scene {
Window { window in
VStack(spacing: 12) {
Label("Hello Portico!")
Button("Click me") { print("clicked!") }
}
}
.title("Portico P0")
.defaultSize(width: 1280, height: 800)
}
}

View file

@ -0,0 +1,28 @@
/// The top-level entry point for a Portico application.
///
/// Conforming types declare a ``Scene`` body and an optional application
/// ID. The `@main` attribute on the concrete type triggers `static func main()`,
/// which delegates to ``PorticoRuntime/run(_:)``.
@MainActor public protocol App {
/// The type of scene representing the body of this app.
associatedtype Body: Scene
/// The top-level scene of the application, composed using ``SceneBuilder``.
@SceneBuilder var body: Body { get }
/// A reverse-DNS application identifier for GTK uniqueness.
///
/// Returns `nil` by default; override to enable single-instance behavior.
var applicationId: String? { get }
/// Initializes the application required by the `App` protocol.
init()
}
public extension App {
/// Default no application ID, no single-instance enforcement.
var applicationId: String? { nil }
/// Boots the application via ``PorticoRuntime/run(_:)``.
static func main() { PorticoRuntime.run(Self()) }
}

View file

@ -0,0 +1,12 @@
import Adw
/// SPI mount requirement for scenes a standalone protocol (NOT a
/// refinement of `Scene`) so `Scene` stays public without exposing SPI
/// in its inheritance clause.
///
/// Dispatch uses `app.body as? MountableScene` (mirrors ``AnyView``'s
/// runtime-cast pattern). For v1 the only conformer is ``Window``.
@_spi(Portico) @MainActor public protocol MountableScene {
/// Attaches the scene to the given application, constructing and showing its window.
func attach(to app: Adw.Application)
}

View file

@ -0,0 +1,40 @@
import Adw
import Gtk
import Gio
#if canImport(Glibc)
import Glibc
#elseif canImport(Darwin)
import Darwin
#endif
/// The runtime that boots a Portico ``App``.
///
/// Creates an `Adw.Application`, wires the ``Scene`` tree via
/// `connectActivate`, runs the GTK main loop, and propagates the exit
/// code to the process.
@_spi(Portico) @MainActor public enum PorticoRuntime {
/// Boots the application, creates an `Adw.Application`, wires the
/// scene tree via `connectActivate`, runs the GTK main loop, and
/// terminates the process with the returned exit code.
///
/// - Precondition: `app.body` must conform to ``MountableScene``
/// (v1 supports a single ``Window`` scene).
/// - Parameter app: The ``App`` instance to run.
public static func run<A: App>(_ app: A) {
let adwApp = Adw.Application(
applicationId: app.applicationId,
flags: .defaultFlags
)
_ = adwApp.connectActivate { _ in
guard let scene = app.body as? MountableScene else {
fatalError(
"Portico: top-level Scene must conform to MountableScene "
+ "(v1 supports a single Window scene)"
)
}
scene.attach(to: adwApp)
}
exit(adwApp.run(argv: CommandLine.arguments))
}
}

View file

@ -0,0 +1,18 @@
/// A part of an application's user interface.
///
/// Scenes compose into the ``App/body-swift.property`` hierarchy.
/// ``Window`` is the primary concrete scene in v1.
@MainActor public protocol Scene {
/// The type of scene representing the body of this scene.
associatedtype Body: Scene
/// The content of the scene, composed using ``SceneBuilder``.
var body: Body { get }
}
/// Terminal conformance `Never` is the empty `Body` type that terminates
/// the `Scene` associated-type chain. `Never`'s `body` witness is supplied
/// by its `View` conformance (`View.swift`) and serves both protocols; a
/// single `body` member covers both, so it is deliberately NOT redeclared
/// here (a second declaration is an "invalid redeclaration" error).
extension Never: Scene {}

View file

@ -0,0 +1,5 @@
/// Result builder for composing ``Scene`` hierarchies.
@resultBuilder public enum SceneBuilder {
/// Passes through a single scene unchanged.
public static func buildBlock<S: Scene>(_ scene: S) -> S { scene }
}

View file

@ -0,0 +1,88 @@
import Adw
import Gtk
/// A window scene that hosts exactly one root ``View``.
///
/// ``Window`` is generic over its content view type, enforcing a single
/// child at compile time. Compose multiple widgets with an explicit
/// container (`VStack`/`HStack`/`Box`).
///
/// The content closure receives the live `Adw.ApplicationWindow` as an
/// imperative escape hatch for configuration not covered by modifiers.
public struct Window<Content: View>: Scene {
/// Builds the window's single root view, given the live application window.
@_spi(Portico) public let content: (Adw.ApplicationWindow) -> Content
/// Creation-time configuration accumulated by the scene modifiers.
@_spi(Portico) public var config = WindowConfig()
public var body: Never { fatalError() }
/// Creates a window hosting the given root view.
///
/// The content closure receives the live `Adw.ApplicationWindow` as an
/// imperative escape hatch for configuration not covered by modifiers.
/// Exactly one view must be returned; use an explicit container
/// (`VStack`/`HStack`/`Box`) for multiple widgets.
public init(content: @escaping (Adw.ApplicationWindow) -> Content) {
self.content = content
}
}
// MARK: - Scene modifiers
public extension Window {
/// Sets the default size of the window, in pixels.
func defaultSize(width: Int32, height: Int32) -> Window {
var c = self
c.config.defaultSize = (width, height)
return c
}
/// Sets the window title.
func title(_ title: String) -> Window {
var c = self
c.config.title = title
return c
}
/// Requests the window to enter fullscreen mode.
func fullscreen(_ enabled: Bool = true) -> Window {
var c = self
c.config.fullscreen = enabled
return c
}
/// Sets whether the window can be resized by the user.
func resizable(_ resizable: Bool = true) -> Window {
var c = self
c.config.resizable = resizable
return c
}
}
// MARK: - MountableScene
@_spi(Portico) extension Window: MountableScene {
@_spi(Portico) public func attach(to app: Adw.Application) {
let window = Adw.ApplicationWindow(app: app)
if let s = config.defaultSize {
window.setDefaultSize(width: s.width, height: s.height)
}
if let t = config.title {
window.setTitle(title: t)
}
if let r = config.resizable {
window.setResizable(resizable: r)
}
if config.fullscreen {
window.fullscreen()
}
let ctx = MountContext()
let child = AnyView(content(window)).makeWidget(ctx)
window.setContent(content: child)
window.present()
}
}

View file

@ -0,0 +1,18 @@
/// Configuration for a ``Window`` scene collected by modifier methods
/// and applied at attach time.
@_spi(Portico) public struct WindowConfig {
/// The default window size in pixels, or `nil` for GTK's default.
@_spi(Portico) public var defaultSize: (width: Int32, height: Int32)? = nil
/// The window title, or `nil` for no title.
@_spi(Portico) public var title: String? = nil
/// Whether the window can be resized by the user, or `nil` for GTK's default.
@_spi(Portico) public var resizable: Bool? = nil
/// Whether the window should start in fullscreen mode.
@_spi(Portico) public var fullscreen: Bool = false
/// Creates an empty configuration with all defaults.
@_spi(Portico) public init() {}
}

View file

@ -0,0 +1,36 @@
import Gtk
/// A configurable-orientation container backed by `Gtk.Box`.
@MainActor public struct Box: View {
let orientation: Gtk.Orientation
let spacing: Int32
let children: [AnyView]
public var body: Never { fatalError() }
/// Creates a box with the given orientation, spacing, and children.
///
/// - Parameters:
/// - orientation: `.vertical` (default) or `.horizontal`.
/// - spacing: Spacing between children in pixels.
/// - content: A ``ViewBuilder`` closure producing the children.
public init(
orientation: Gtk.Orientation = .vertical,
spacing: Int32 = 0,
@ViewBuilder content: () -> [AnyView]
) {
self.orientation = orientation
self.spacing = spacing
self.children = content()
}
}
@_spi(Portico) extension Box: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let box = Gtk.Box(orientation: orientation, spacing: spacing)
for child in children {
box.appendChild(child.makeWidget(ctx))
}
return box
}
}

View file

@ -0,0 +1,15 @@
import Gtk
@_spi(Portico) extension Gtk.Box: SequentialContainer {
/// Delegates to `Gtk.Box.append(child:)`, using the distinct SPI name
/// to avoid recursion with the wrapper's own `append` method.
@_spi(Portico) public func appendChild(_ child: Gtk.Widget) {
append(child: child)
}
/// Delegates to `Gtk.Box.remove(child:)`, using the distinct SPI name
/// to avoid recursion with the wrapper's own `remove` method.
@_spi(Portico) public func removeChild(_ child: Gtk.Widget) {
remove(child: child)
}
}

View file

@ -0,0 +1,32 @@
import Gtk
/// A horizontal container backed by `Gtk.Box(orientation: .horizontal)`.
@MainActor public struct HStack: View {
let spacing: Int32
let children: [AnyView]
public var body: Never { fatalError() }
/// Creates a horizontal stack with the given spacing and children.
///
/// - Parameters:
/// - spacing: Spacing between children in pixels.
/// - content: A ``ViewBuilder`` closure producing the children.
public init(
spacing: Int32 = 0,
@ViewBuilder content: () -> [AnyView]
) {
self.spacing = spacing
self.children = content()
}
}
@_spi(Portico) extension HStack: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let box = Gtk.Box(orientation: .horizontal, spacing: spacing)
for child in children {
box.appendChild(child.makeWidget(ctx))
}
return box
}
}

View file

@ -0,0 +1,13 @@
import Gtk
/// SPI protocol for containers that manage an ordered list of children.
///
/// Method names `appendChild`/`removeChild` avoid collision with the
/// wrapper types' own `append`/`remove` methods.
@_spi(Portico) @MainActor public protocol SequentialContainer {
/// Appends a child widget to the end of the container.
func appendChild(_ child: Gtk.Widget)
/// Removes a previously added child widget.
func removeChild(_ child: Gtk.Widget)
}

View file

@ -0,0 +1,9 @@
import Gtk
/// SPI protocol for containers that host exactly one child widget.
///
/// No P0 conformance declared now for later phases (single-child sugar).
@_spi(Portico) @MainActor public protocol SingleChildContainer {
/// Attaches or detaches a child widget.
func attachChild(_ child: Gtk.Widget?)
}

View file

@ -0,0 +1,32 @@
import Gtk
/// A vertical container backed by `Gtk.Box(orientation: .vertical)`.
@MainActor public struct VStack: View {
let spacing: Int32
let children: [AnyView]
public var body: Never { fatalError() }
/// Creates a vertical stack with the given spacing and children.
///
/// - Parameters:
/// - spacing: Spacing between children in pixels.
/// - content: A ``ViewBuilder`` closure producing the children.
public init(
spacing: Int32 = 0,
@ViewBuilder content: () -> [AnyView]
) {
self.spacing = spacing
self.children = content()
}
}
@_spi(Portico) extension VStack: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let box = Gtk.Box(orientation: .vertical, spacing: spacing)
for child in children {
box.appendChild(child.makeWidget(ctx))
}
return box
}
}

View file

@ -0,0 +1,36 @@
import Gtk
/// Type-erased `View` that stores a mount thunk (build-once, bind-live).
///
/// Uses runtime casts to dispatch: `Mountable` primitives/containers are
/// called directly; user structs evaluate `body` once and recurse.
/// ``AnyView`` deliberately does NOT conform to `Mountable`
/// the `as? AnyView` branch handles nesting without name clashes.
public struct AnyView: View {
/// The mount thunk: builds this view's `Gtk.Widget` exactly once,
/// given a mount context.
@_spi(Portico) public let makeWidget: (MountContext) -> Gtk.Widget
public var body: Never { fatalError() }
/// Wraps a pre-built mount thunk directly; used by container mounts
/// that already hold a thunk.
@_spi(Portico) public init(makeWidget: @escaping (MountContext) -> Gtk.Widget) {
self.makeWidget = makeWidget
}
/// Creates a type-erased view wrapping the given view.
///
/// Dispatch order: `AnyView` passthrough ``Mountable`` direct mount
/// user struct: evaluate `body` once and recurse.
public init<V: View>(_ view: V) {
if let any = view as? AnyView {
self.makeWidget = any.makeWidget
} else if let m = view as? Mountable {
self.makeWidget = { ctx in m.mount(ctx) }
} else {
// User struct: evaluate body once at mount time.
self.makeWidget = { ctx in AnyView(view.body).makeWidget(ctx) }
}
}
}

View file

@ -0,0 +1,9 @@
import Gtk
/// Context threaded through the mount pipeline.
///
/// Empty in P0; reserved for the P4 signal/subscription registry needed
/// for per-node teardown on unmount.
@_spi(Portico) @MainActor public final class MountContext {
@_spi(Portico) public init() {}
}

View file

@ -0,0 +1,10 @@
import Gtk
/// SPI mount requirement types that back directly to a `Gtk.Widget`
/// conform to this protocol so ``AnyView`` can call `mount` via
/// runtime-cast dispatch without exposing `Mountable` in the public
/// `View` hierarchy.
@_spi(Portico) @MainActor public protocol Mountable {
/// Mounts the receiver into a `Gtk.Widget`, using the given context.
func mount(_ ctx: MountContext) -> Gtk.Widget
}

View file

@ -0,0 +1,24 @@
import Gtk
/// The core protocol for declarative UI components in Portico.
///
/// Conforming types describe their visual hierarchy in `body` using the
/// ``ViewBuilder`` result builder. The body is evaluated exactly once at
/// mount time Portico uses a build-once, bind-live model rather than
/// recompute-and-diff.
@MainActor public protocol View {
/// The type of view representing the body of this view.
associatedtype Body: View
/// The content and behavior of the view, composed using ``ViewBuilder``.
///
/// The body is evaluated exactly once at mount time Portico uses a
/// build-once, bind-live model rather than recompute-and-diff.
var body: Body { get }
}
/// Terminal conformance `Never` has no body and terminates the
/// `View` associated-type chain.
extension Never: View {
public var body: Never { fatalError("Never has no body") }
}

View file

@ -0,0 +1,32 @@
/// Result builder for composing ``View`` hierarchies.
///
/// Conditionals (`buildOptional`/`buildEither`) are passthrough in P0
/// (they collapse to plain arrays) correct for the static-only scope.
/// P2 replaces them to emit `OptionalView`/`EitherView` nodes.
@resultBuilder public enum ViewBuilder {
/// Wraps a single ``View`` in an ``AnyView``.
public static func buildExpression<V: View>(_ v: V) -> [AnyView] { [AnyView(v)] }
public static func buildBlock(_ parts: [AnyView]...) -> [AnyView] { parts.flatMap { $0 } }
/// Passes through an optional branch, collapsing to empty when `nil`.
public static func buildOptional(_ part: [AnyView]?) -> [AnyView] {
part ?? []
}
/// Passes through the first branch of an `if`/`else`.
public static func buildEither(first: [AnyView]) -> [AnyView] { first }
/// Passes through the second branch of an `if`/`else`.
public static func buildEither(second: [AnyView]) -> [AnyView] { second }
/// Flattens the results of a `for`-loop into a single array.
public static func buildArray(_ parts: [[AnyView]]) -> [AnyView] {
parts.flatMap { $0 }
}
/// Passes through an availability-guarded branch unchanged.
public static func buildLimitedAvailability(_ part: [AnyView]) -> [AnyView] {
part
}
}

View file

@ -0,0 +1,44 @@
import Gtk
/// A button with a click action, backed by `Gtk.Button`.
///
/// Supports text labels and icon-name variants. The `connectClicked`
/// ``SignalHandle`` is intentionally discarded in P0 the closure box
/// is retained by GObject for the widget's lifetime. Per-node teardown
/// is wired in P4.
@MainActor public struct Button: View {
private enum Kind {
case label(String)
case icon(String)
}
private let kind: Kind
@_spi(Portico) public let action: () -> Void
public var body: Never { fatalError() }
/// Creates a button with a text label and a click action.
public init(_ label: String, action: @escaping () -> Void) {
self.kind = .label(label)
self.action = action
}
/// Creates a button with a themed icon and a click action.
public init(iconName: String, action: @escaping () -> Void) {
self.kind = .icon(iconName)
self.action = action
}
}
@_spi(Portico) extension Button: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let button: Gtk.Button
switch kind {
case .label(let s): button = Gtk.Button(label: s)
case .icon(let s): button = Gtk.Button(iconName: s)
}
let action = self.action
_ = button.connectClicked { _ in action() }
return button
}
}

View file

@ -0,0 +1,19 @@
import Gtk
/// A static label widget backed by `Gtk.Label`.
///
/// ``Text`` and `Label` are identical in P0 both mount a `Gtk.Label`.
/// `Label` mirrors the wrapper type name; ``Text`` mirrors SwiftUI naming.
@MainActor public struct Label: View {
let text: String
public var body: Never { fatalError() }
/// Creates a label displaying the given text.
public init(_ text: String) { self.text = text }
}
@_spi(Portico) extension Label: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
Gtk.Label(str: text)
}
}

View file

@ -0,0 +1,19 @@
import Gtk
/// A static text widget backed by `Gtk.Label`.
///
/// `Text` and ``Label`` are identical in P0 both mount a `Gtk.Label`.
/// `Text` mirrors SwiftUI naming; ``Label`` mirrors the wrapper type name.
@MainActor public struct Text: View {
let string: String
public var body: Never { fatalError() }
/// Creates a text widget displaying the given string.
public init(_ string: String) { self.string = string }
}
@_spi(Portico) extension Text: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
Gtk.Label(str: string)
}
}

View file

@ -0,0 +1,42 @@
import Testing
import Gtk
@_spi(Portico) import Portico
@MainActor @Suite(.serialized) struct MountTests {
private func childCount(_ w: Gtk.Widget) -> Int {
var n = 0
var cur = w.getFirstChild()
while let c = cur { n += 1; cur = c.getNextSibling() }
return n
}
@Test func vstackAppendsChildrenInOrder() {
guard Gtk.initCheck() else { return }
let widget = AnyView(VStack(spacing: 0) {
Label("a")
Button("b") {}
}).makeWidget(MountContext())
#expect(childCount(widget) == 2)
}
@Test func hstackMountsLabelAndText() {
guard Gtk.initCheck() else { return }
let widget = AnyView(HStack(spacing: 0) {
Label("a")
Text("b")
}).makeWidget(MountContext())
#expect(childCount(widget) == 2)
}
@Test func buttonStoresAction() {
// Validates Portico-level contract: Button stores its action
// closure, and it is callable synchronously. The connectClicked
// signal wiring cannot be tested synchronously in P0 GTK's
// activateclicked chain requires a realized widget and main-loop
// iteration (~250ms timer), out of scope for unit tests.
var fired = false
let button = Button("x") { fired = true }
button.action()
#expect(fired)
}
}

View file

@ -0,0 +1,31 @@
import Testing
@_spi(Portico) import Portico
@Suite struct ViewBuilderTests {
@ViewBuilder private func mixed(_ flag: Bool) -> [AnyView] {
Label("a")
if flag { Label("b") }
}
@ViewBuilder private func either(_ flag: Bool) -> [AnyView] {
if flag { Label("a") } else { Label("b"); Label("c") }
}
@ViewBuilder private func loop() -> [AnyView] {
for _ in 0..<3 { Label("x") }
}
@Test func mixedPlainAndConditional() {
#expect(mixed(true).count == 2)
#expect(mixed(false).count == 1)
}
@Test func eitherBranches() {
#expect(either(true).count == 1)
#expect(either(false).count == 2)
}
@Test func forLoopFlattens() {
#expect(loop().count == 3)
}
}

View file

@ -0,0 +1,25 @@
import Testing
@_spi(Portico) import Portico
@Suite struct WindowConfigTests {
@Test func modifiersAccumulate() {
let w = Window { _ in Label("x") }
.title("T")
.defaultSize(width: 100, height: 200)
.resizable(false)
.fullscreen()
#expect(w.config.title == "T")
#expect(w.config.defaultSize?.width == 100)
#expect(w.config.defaultSize?.height == 200)
#expect(w.config.resizable == false)
#expect(w.config.fullscreen == true)
}
@Test func defaultConfigIsEmpty() {
let w = Window { _ in Label("x") }
#expect(w.config.title == nil)
#expect(w.config.defaultSize == nil)
#expect(w.config.resizable == nil)
#expect(w.config.fullscreen == false)
}
}