Foundation implementation
This commit is contained in:
commit
145d0a710d
27 changed files with 739 additions and 0 deletions
43
.gitignore
vendored
Normal file
43
.gitignore
vendored
Normal 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
42
Package.swift
Normal 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
|
||||
),
|
||||
]
|
||||
)
|
||||
17
Sources/Example/ExampleApp.swift
Normal file
17
Sources/Example/ExampleApp.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
28
Sources/Portico/App/App.swift
Normal file
28
Sources/Portico/App/App.swift
Normal 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()) }
|
||||
}
|
||||
12
Sources/Portico/App/MountableScene.swift
Normal file
12
Sources/Portico/App/MountableScene.swift
Normal 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)
|
||||
}
|
||||
40
Sources/Portico/App/PorticoRuntime.swift
Normal file
40
Sources/Portico/App/PorticoRuntime.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
18
Sources/Portico/App/Scene.swift
Normal file
18
Sources/Portico/App/Scene.swift
Normal 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 {}
|
||||
5
Sources/Portico/App/SceneBuilder.swift
Normal file
5
Sources/Portico/App/SceneBuilder.swift
Normal 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 }
|
||||
}
|
||||
88
Sources/Portico/App/Window.swift
Normal file
88
Sources/Portico/App/Window.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
18
Sources/Portico/App/WindowConfig.swift
Normal file
18
Sources/Portico/App/WindowConfig.swift
Normal 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() {}
|
||||
}
|
||||
36
Sources/Portico/Containers/Box.swift
Normal file
36
Sources/Portico/Containers/Box.swift
Normal 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
|
||||
}
|
||||
}
|
||||
15
Sources/Portico/Containers/GtkBox+SequentialContainer.swift
Normal file
15
Sources/Portico/Containers/GtkBox+SequentialContainer.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
32
Sources/Portico/Containers/HStack.swift
Normal file
32
Sources/Portico/Containers/HStack.swift
Normal 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
|
||||
}
|
||||
}
|
||||
13
Sources/Portico/Containers/SequentialContainer.swift
Normal file
13
Sources/Portico/Containers/SequentialContainer.swift
Normal 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)
|
||||
}
|
||||
9
Sources/Portico/Containers/SingleChildContainer.swift
Normal file
9
Sources/Portico/Containers/SingleChildContainer.swift
Normal 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?)
|
||||
}
|
||||
32
Sources/Portico/Containers/VStack.swift
Normal file
32
Sources/Portico/Containers/VStack.swift
Normal 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
|
||||
}
|
||||
}
|
||||
36
Sources/Portico/Core/AnyView.swift
Normal file
36
Sources/Portico/Core/AnyView.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
9
Sources/Portico/Core/MountContext.swift
Normal file
9
Sources/Portico/Core/MountContext.swift
Normal 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() {}
|
||||
}
|
||||
10
Sources/Portico/Core/Mountable.swift
Normal file
10
Sources/Portico/Core/Mountable.swift
Normal 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
|
||||
}
|
||||
24
Sources/Portico/Core/View.swift
Normal file
24
Sources/Portico/Core/View.swift
Normal 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") }
|
||||
}
|
||||
32
Sources/Portico/Core/ViewBuilder.swift
Normal file
32
Sources/Portico/Core/ViewBuilder.swift
Normal 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
|
||||
}
|
||||
}
|
||||
44
Sources/Portico/Widgets/Button.swift
Normal file
44
Sources/Portico/Widgets/Button.swift
Normal 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
|
||||
}
|
||||
}
|
||||
19
Sources/Portico/Widgets/Label.swift
Normal file
19
Sources/Portico/Widgets/Label.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
19
Sources/Portico/Widgets/Text.swift
Normal file
19
Sources/Portico/Widgets/Text.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
42
Tests/PorticoTests/MountTests.swift
Normal file
42
Tests/PorticoTests/MountTests.swift
Normal 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
|
||||
// activate→clicked 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)
|
||||
}
|
||||
}
|
||||
31
Tests/PorticoTests/ViewBuilderTests.swift
Normal file
31
Tests/PorticoTests/ViewBuilderTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
25
Tests/PorticoTests/WindowConfigTests.swift
Normal file
25
Tests/PorticoTests/WindowConfigTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue