Add path-driven NavigationView with NavigationStack semantics

This commit is contained in:
Brendan Szymanski 2026-08-02 16:24:43 -04:00
parent 772a3c3a71
commit b398edf3ee
9 changed files with 974 additions and 21 deletions

View file

@ -60,6 +60,7 @@ let package = Package(
"Portico",
"PorticoGtk",
.product(name: "Adw", package: "gtk-swift"),
.product(name: "Gio", package: "gtk-swift"),
.product(name: "Gtk", package: "gtk-swift"),
],
swiftSettings: swiftSettings

View file

@ -28,14 +28,17 @@ private struct StatusPane: View {
@Environment(DemoModel.self) private var model
var body: some View {
VStack(spacing: 8) {
VStack {
@Bindable var model = model
Label($model.label)
.title2()
VStack(spacing: 8) {
Label($model.label)
.title2()
Label("Font: \(Int(model.fontSize))pt, \(model.isDark ? "Dark" : "Light")")
.dimmed()
Label("Font: \(Int(model.fontSize))pt, \(model.isDark ? "Dark" : "Light")")
.dimmed()
}
.margin(16)
}
.card()
.halign(.center)

View file

@ -1,5 +1,7 @@
import Portico
private struct ExamplePark: Hashable { let name: String }
@main
struct ExampleApp: App {
var applicationId: String? { "dev.bscubed.PorticoExample" }
@ -9,30 +11,50 @@ struct ExampleApp: App {
@State private var darkMode = false
@State private var volume = 75.0
@State private var currentPage: UInt32 = 0
@State private var parkPath: [ExamplePark] = []
@State private var uptime = 0
var body: some Scene {
ApplicationWindow { _ in
VStack {
HeaderBar()
NavigationView(path: $parkPath) {
VStack {
HeaderBar()
Carousel {
introPage
counterPage
settingsPage
AsyncDemoPage()
EnvironmentDemoPage()
Carousel {
introPage
counterPage
settingsPage
AsyncDemoPage()
EnvironmentDemoPage()
navigationDemoPage
}
.onPageChanged { page in currentPage = page }
Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 6")
.dimmed()
.halign(.center)
.margin(8)
.task(every: .seconds(1)) { uptime += 1 }
}
.navigationTitle("Portico Demo")
.navigationDestination(for: ExamplePark.self) { park in
VStack {
HeaderBar()
Label("Welcome to \(park.name)")
.title1()
.halign(.center)
.margin(24)
Label("This page was pushed programmatically via the path binding.")
.dimmed()
.halign(.center)
}
.hexpand(true)
.vexpand(true)
.navigationTitle(park.name)
}
.onPageChanged { page in currentPage = page }
Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 5")
.dimmed()
.halign(.center)
.margin(8)
.task(every: .seconds(1)) { uptime += 1 }
}
}
.title("Portico Demo")
.defaultSize(width: 1080, height: 720)
}
@ -104,4 +126,34 @@ struct ExampleApp: App {
.hexpand(true)
.vexpand(true)
}
var navigationDemoPage: some View {
Clamp {
StatusPage {
ListBox {
ButtonRow()
.title("See Yosemite")
.onActivated {
parkPath.append(ExamplePark(name: "Yosemite"))
}
ButtonRow()
.title("See Yellowstone")
.onActivated {
parkPath.append(ExamplePark(name: "Yellowstone"))
}
ButtonRow()
.title("See Zion")
.onActivated {
parkPath.append(ExamplePark(name: "Zion"))
}
}
.boxedList()
}
.title("Navigation")
.description("Navigating is easy and reactive in Portico! Just bind an array to a NavigationView and it updates when changes are made in either direction!")
}
.valign(.center)
.hexpand(true)
.vexpand(true)
}
}

View file

@ -126,6 +126,17 @@ import Gdk
}
}
// PorticoGen: generateInits(static) | source: Gtk.DropDown.init(strings:)
/// Creates a new `GtkDropDown` that is populated with
/// the strings.
///
/// Applied once at mount; use the `Binding` or closure overload for values that change.
///
/// - Parameter strings: The `strings` value forwarded to `Gtk.DropDown`.
public init(strings: [String]) {
make = { _ in Gtk.DropDown(strings: strings) }
}
}
extension DropDown: WidgetView {

View file

@ -5,6 +5,75 @@ import Gtk
import Gio
import Gdk
// PorticoGen: generateStruct | source: Gtk.ScaleButton
/// Provides a button which pops up a scale widget.
///
/// This kind of widget is commonly used for volume controls in multimedia
/// applications, and GTK provides a [class`Gtk`.VolumeButton] subclass that
/// is tailored for this use case.
///
/// # Shortcuts and Gestures
///
/// The following signals have default keybindings:
///
/// - [signal`Gtk`.ScaleButton::popup]
///
/// # CSS nodes
///
/// ```
/// scalebutton.scale
/// button.toggle
/// <icon>
/// ```
///
/// `GtkScaleButton` has a single CSS node with name scalebutton and `.scale`
/// style class, and contains a `button` node with a `.toggle` style class.
///
/// A Portico view that mounts a `Gtk.ScaleButton`.
@MainActor public struct ScaleButton: View {
private let make: (MountContext) -> Gtk.ScaleButton
private var configure: [(Gtk.ScaleButton, MountContext) -> Void] = []
public var body: Never { fatalError() }
// PorticoGen: generateInits(static) | source: Gtk.ScaleButton.init(min:max:step:icons:)
/// Creates a `GtkScaleButton`.
///
/// The new scale button has a range between `min` and `max`,
/// with a stepping of `step`.
///
/// Applied once at mount; use the `Binding` or closure overload for values that change.
///
/// - Parameter min: The `min` value forwarded to `Gtk.ScaleButton`.
/// - Parameter max: The `max` value forwarded to `Gtk.ScaleButton`.
/// - Parameter step: The `step` value forwarded to `Gtk.ScaleButton`.
/// - Parameter icons: Sets the icons to be used by the scale button.
public init(min: Double, max: Double, step: Double, icons: [String]) {
make = { _ in Gtk.ScaleButton(min: min, max: max, step: step, icons: icons) }
}
}
extension ScaleButton: WidgetView {
public typealias Target = Gtk.ScaleButton
@_spi(Portico) public func appending(
_ step: @escaping (Gtk.ScaleButton, MountContext) -> Void
) -> Self {
var c = self
c.configure.append(step)
return c
}
}
@_spi(Portico) extension ScaleButton: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
let w = make(ctx)
for step in configure { step(w, ctx) }
return w
}
}
// PorticoGen: generateModifierExtension | source: Gtk.ScaleButton
/// Modifiers for `Gtk.ScaleButton`, available on every Portico view whose
/// backing widget is `Gtk.ScaleButton` or one of its subclasses.

View file

@ -0,0 +1,118 @@
import Adw
import Gtk
// MARK: - Destination Registry
/// Maps a destination value's dynamic type to the builder that renders it.
///
/// Registration happens at mount time: `.navigationDestination(for:)` looks the
/// registry up in the environment injected by the enclosing `NavigationView`.
/// A second registration for the same type replaces the first, so the innermost
/// (latest-mounted) declaration wins, matching SwiftUI.
@MainActor final class NavigationDestinationRegistry {
private var builders: [ObjectIdentifier: (AnyHashable) -> [AnyView]] = [:]
func register<D: Hashable>(_ type: D.Type, _ build: @escaping (D) -> [AnyView]) {
builders[ObjectIdentifier(type)] = { erased in
guard let value = erased.base as? D else { return [] }
return build(value)
}
}
/// The views for `value`, or `nil` when no builder is registered for its
/// exact dynamic type.
func build(_ value: AnyHashable) -> [AnyView]? {
builders[ObjectIdentifier(type(of: value.base))]?(value)
}
}
// MARK: - Environment slots
private enum NavigationDestinationsKey: EnvironmentKey {
static var defaultValue: NavigationDestinationRegistry? { nil }
}
private enum NavigationPageKey: EnvironmentKey {
static var defaultValue: Adw.NavigationPage? { nil }
}
extension EnvironmentValues {
/// The destination registry of the innermost enclosing `NavigationView`.
var navigationDestinations: NavigationDestinationRegistry? {
get { self[NavigationDestinationsKey.self] }
set { self[NavigationDestinationsKey.self] = newValue }
}
/// The `Adw.NavigationPage` this subtree is mounted into.
var navigationPage: Adw.NavigationPage? {
get { self[NavigationPageKey.self] }
set { self[NavigationPageKey.self] = newValue }
}
}
// MARK: - navigationDestination(for:destination:)
extension View {
/// Associates a destination view with a presented data type, for use within
/// the enclosing path-driven `NavigationView`.
///
/// Registration happens when this view mounts, so a destination declared
/// inside another destination is only available once that outer page has
/// been pushed - the same lazy-registration caveat SwiftUI has.
///
/// - Parameters:
/// - data: The exact type of value this destination renders. Lookup is by
/// dynamic type; subclasses are not matched.
/// - destination: Builds the page content from a value snapshot.
/// - Returns: The receiver, unchanged, with the destination registered.
public func navigationDestination<D: Hashable>(
for data: D.Type,
@ViewBuilder destination: @escaping (D) -> [AnyView]
) -> AnyView {
AnyView(makeWidget: { ctx in
ctx.environment.navigationDestinations?.register(data, destination)
return AnyView(self).makeWidget(ctx)
})
}
}
// MARK: - navigationTitle
extension View {
/// Sets the title of the `Adw.NavigationPage` this view is mounted into
/// (static, applied once at mount).
///
/// `Adw.HeaderBar` inside the page displays this title automatically, and
/// the next page's back button uses it as its tooltip. No-op outside a
/// `NavigationView` page.
public func navigationTitle(_ title: String) -> AnyView {
AnyView(makeWidget: { ctx in
ctx.environment.navigationPage?.setTitle(title: title)
return AnyView(self).makeWidget(ctx)
})
}
/// Sets the page title, tracking a ``Binding``.
public func navigationTitle(_ title: Binding<String>) -> AnyView {
AnyView(makeWidget: { ctx in
if let page = ctx.environment.navigationPage {
page.setTitle(title: title.wrappedValue)
ctx.registry.add(title.subscribe { [page] t in page.setTitle(title: t) })
}
return AnyView(self).makeWidget(ctx)
})
}
/// Sets the page title from a tracked closure, re-evaluated when any
/// `@State` or observable property it reads changes.
public func navigationTitle(_ title: @escaping () -> String) -> AnyView {
AnyView(makeWidget: { ctx in
if let page = ctx.environment.navigationPage {
let tracker = DependencyTracker { [page] in page.setTitle(title: title()) }
tracker.run()
ctx.registry.add(tracker)
}
return AnyView(self).makeWidget(ctx)
})
}
}

View file

@ -0,0 +1,124 @@
import Foundation
// MARK: - NavigationPath
nonisolated public struct NavigationPath: Equatable {
/// The erased destination values, root-most first. Consumed by
/// `NavigationView`'s path-driven initializer.
@_spi(Portico) public private(set) var elements: [AnyHashable]
/// Creates an empty path.
public init() { self.elements = [] }
/// Creates a path from a sequence of homogeneous destination values.
public init<S: Sequence>(_ elements: S) where S.Element: Hashable {
self.elements = elements.map { AnyHashable($0) }
}
/// Creates a path from already-erased values, used when writing a truncated
/// stack back into the binding.
@_spi(Portico) public init(erased elements: [AnyHashable]) {
self.elements = elements
}
/// The number of destinations on the path.
public var count: Int { elements.count }
/// Whether the path presents no destinations.
public var isEmpty: Bool { elements.isEmpty }
/// Appends a destination value of any `Hashable` type.
public mutating func append<V: Hashable>(_ value: V) {
elements.append(AnyHashable(value))
}
/// Removes the last `k` destinations, clamped to the path's length.
///
/// Unlike SwiftUI's `NavigationPath.removeLast(_:)`, an out-of-range `k`
/// clamps instead of trapping: a widget-initiated pop and a programmatic
/// `removeLast` can observe the same path in either order, and a crash is
/// the wrong outcome for that race.
public mutating func removeLast(_ k: Int = 1) {
elements.removeLast(min(max(k, 0), elements.count))
}
}
// MARK: - CodableRepresentation
extension NavigationPath {
/// A `Codable` snapshot of a path whose every element is `Codable`.
///
/// Encodes as a flat unkeyed container of `2 * count` strings, root-most
/// element first: each element contributes its mangled type name followed
/// by its UTF-8 JSON encoding. Decoding resolves each type name through
/// `_typeByName`, so a type that was renamed, moved module, or is absent
/// from the decoding binary fails with `DecodingError.dataCorrupted`.
public struct CodableRepresentation: Codable {
let elements: [AnyHashable]
init(elements: [AnyHashable]) {
self.elements = elements
}
public init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
var decoded: [AnyHashable] = []
while !container.isAtEnd {
let name = try container.decode(String.self)
let json = try container.decode(String.self)
guard let resolved = _typeByName(name) as? any (Decodable & Hashable).Type else {
throw DecodingError.dataCorrupted(
.init(codingPath: container.codingPath,
debugDescription: "NavigationPath: unknown element type \(name)")
)
}
decoded.append(try _decodeNavigationElement(resolved, from: Data(json.utf8)))
}
self.elements = decoded
}
public func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
for element in elements {
guard let encodable = element.base as? any Encodable,
let name = _mangledTypeName(type(of: element.base))
else {
throw EncodingError.invalidValue(
element.base,
.init(codingPath: container.codingPath,
debugDescription: "NavigationPath: element is not Encodable")
)
}
try container.encode(name)
try container.encode(String(decoding: JSONEncoder().encode(encodable), as: UTF8.self))
}
}
}
/// A `Codable` snapshot, or `nil` when any element's type is not `Codable`
/// (matching SwiftUI, where one non-codable element makes the whole path
/// unserializable rather than partially serializable).
public var codable: CodableRepresentation? {
for element in elements {
guard element.base is any Codable,
_mangledTypeName(type(of: element.base)) != nil
else { return nil }
}
return CodableRepresentation(elements: elements)
}
/// Restores a path from a decoded snapshot.
public init(_ codable: CodableRepresentation) {
self.elements = codable.elements
}
}
/// Decodes one erased element. A free generic function so that passing an
/// `any (Decodable & Hashable).Type` opens the existential into `T` (SE-0352
/// implicit existential opening); the metatype cannot be handed to
/// `JSONDecoder.decode` directly.
private func _decodeNavigationElement<T: Decodable & Hashable>(
_ type: T.Type, from data: Data
) throws -> AnyHashable {
AnyHashable(try JSONDecoder().decode(T.self, from: data))
}

View file

@ -0,0 +1,244 @@
import Foundation
import Adw
import Gtk
// MARK: - Path access shim
/// Type-erased read/write access to a `NavigationView`'s bound path.
///
/// `read` MUST go through `Binding.wrappedValue` so the enclosing
/// ``DependencyTracker`` registers with the backing state box; `write` MUST go
/// through the setter so the change notifies.
struct NavigationPathAccess {
let read: () -> [AnyHashable]
let write: ([AnyHashable]) -> Void
}
// MARK: - Page construction helper
/// Wraps `content` in a fresh `Adw.NavigationPage` tagged `tag`.
///
/// The holder `Gtk.Box` lets the page exist before its content mounts:
/// `Adw.NavigationPage` requires a child at construction, while the content must
/// see the page in its environment for `.navigationTitle` to work.
private func makeNavigationPage(
tag: String, content: [AnyView], in scope: MountContext
) -> Adw.NavigationPage {
let holder = Gtk.Box(orientation: .vertical, spacing: 0)
let page = Adw.NavigationPage(child: holder, title: "", tag: tag)
let pageCtx = scope.withEnvironment { $0.navigationPage = page }
for w in content.map({ $0.makeWidget(pageCtx) }) { holder.append(child: w) }
return page
}
// MARK: - Public initializers
public extension NavigationView {
/// Creates a navigation view showing a single root page, with no bound path.
///
/// Static: no reactivity and no subscriptions are installed.
init(@ViewBuilder root: () -> [AnyView]) {
self.init()
self = self.navigationRoot(root(), path: nil)
}
/// Creates a navigation view whose page stack mirrors a homogeneous path.
///
/// One page is pushed per path element, on top of a permanent root page.
/// Appending to `path` pushes; truncating it pops. Widget-initiated pops
/// (Escape, header-bar back button, swipe) truncate `path` in turn, and a
/// forward gesture re-pushes the most recently removed element.
///
/// Reactivity requires a state-backed `Binding` (e.g. `$parks`); a custom
/// `Binding(get:set:)` does not track and will not update.
///
/// - Parameters:
/// - path: The reactive stack of presented values.
/// - root: The always-present root page's content.
init<Data: Hashable>(
path: Binding<[Data]>,
@ViewBuilder root: () -> [AnyView]
) {
self.init()
self = self.navigationRoot(
root(),
path: NavigationPathAccess(
read: { path.wrappedValue.map { AnyHashable($0) } },
write: { erased in path.wrappedValue = erased.compactMap { $0.base as? Data } }
)
)
}
/// Creates a navigation view whose page stack mirrors a heterogeneous
/// ``NavigationPath``, allowing pages of different value types.
init(
path: Binding<NavigationPath>,
@ViewBuilder root: () -> [AnyView]
) {
self.init()
self = self.navigationRoot(
root(),
path: NavigationPathAccess(
read: { path.wrappedValue.elements },
write: { erased in path.wrappedValue = NavigationPath(erased: erased) }
)
)
}
}
// MARK: - Reconciler
private extension NavigationView {
/// Returns a copy of this view with the configure step that builds the root
/// page and, when `path` is non-nil, installs the path <-> stack reconciler.
func navigationRoot(
_ rootContent: [AnyView], path: NavigationPathAccess?
) -> NavigationView {
appending { nav, ctx in
let destinations = NavigationDestinationRegistry()
let contentCtx = ctx.withEnvironment { $0.navigationDestinations = destinations }
// The root page is `add`ed, not pushed: Adw keeps added pages
// forever (so it is never destroyed and is always a valid
// `popToPage`/`replace` anchor), and adding while nothing is visible
// pushes it automatically. It shares the view's registry - it never
// unmounts on its own.
let rootPage = makeNavigationPage(
tag: "portico.nav.root", content: rootContent, in: contentCtx
)
nav.add(page: rootPage)
guard let path else { return }
typealias Entry = (
key: AnyHashable, tag: String,
page: Adw.NavigationPage, registry: NodeRegistry
)
// Live mirror of the pushed pages, one entry per path element, in
// order. Captured by the tracker and all three signal handlers,
// which therefore share one storage box (the `ForEach` `rows`
// pattern).
var pages: [Entry] = []
// Browser-style forward stack of values removed by a back
// navigation; `last` is the next to restore. Cleared by any push of
// a genuinely new element.
var forward: [AnyHashable] = []
// Page built for the in-flight `get-next-page` emission, not yet
// committed. Adw may emit that signal repeatedly for one gesture.
var pending: Entry?
var seq: UInt64 = 0
@MainActor func makeEntry(_ key: AnyHashable) -> Entry {
seq += 1
let tag = "portico.nav.\(seq)"
let scope = contentCtx.makeChild()
let content = destinations.build(key)
if content == nil {
FileHandle.standardError.write(Data("""
Portico: NavigationView has no .navigationDestination \
for \(type(of: key.base)); using an empty page.
""".utf8))
}
return (key, tag, makeNavigationPage(tag: tag, content: content ?? [], in: scope),
scope.registry)
}
@MainActor func release(_ entries: [Entry]) {
for e in entries {
e.registry.teardown()
ctx.registry.removeChild(e.registry)
}
}
@MainActor func applyPath() {
let target = path.read() // tracking read: registers this tracker
var common = 0
while common < target.count, common < pages.count,
pages[common].key == target[common] {
common += 1
}
let removing = pages.count - common
let adding = target.count - common
guard removing > 0 || adding > 0 else { return }
if removing > 0 && adding == 0 {
// Pure truncation. Drop mirror entries BEFORE popping:
// `popToPage` emits `popped` per page, possibly
// synchronously, and with the tags already gone that
// handler cannot re-enter this diff.
let doomed = Array(pages[common...])
pages.removeSubrange(common...)
_ = nav.popToPage(page: pages.last?.page ?? rootPage)
forward.append(contentsOf: doomed.map(\.key).reversed())
release(doomed)
} else if removing == 0 {
// Pure append.
let added = target[common...].map(makeEntry)
pages.append(contentsOf: added)
for e in added { nav.push(page: e.page) }
forward.removeAll()
} else {
// Divergence: one atomic, animation-free replace.
let doomed = Array(pages[common...])
pages.removeSubrange(common...)
pages.append(contentsOf: target[common...].map(makeEntry))
nav.replace(pages: [rootPage] + pages.map(\.page))
release(doomed)
forward.removeAll()
}
// A cancelled forward gesture can leave a page built for a value
// that is no longer next; drop it rather than keep it alive
// until the whole view unmounts.
if let p = pending, p.key != forward.last {
release([p])
pending = nil
}
}
// `observing: false` because the body mounts subtrees - see
// `DependencyTracker`'s doc comment.
let tracker = DependencyTracker(observing: false) { applyPath() }
tracker.run()
ctx.registry.add(tracker)
// Widget-initiated pops (Escape, header-bar back button, swipe) are
// a second writer of the path; mirror them back into the binding.
ctx.registry.add(nav.connectPopped { _, popped in
guard let tag = popped.getTag(),
let idx = pages.firstIndex(where: { $0.tag == tag })
else { return } // already reconciled by `applyPath`, or the root
let doomed = Array(pages[idx...])
pages.removeSubrange(idx...)
forward.append(contentsOf: doomed.map(\.key).reversed())
path.write(pages.map(\.key))
release(doomed)
})
// Forward shortcut/gesture. MUST be pure: Adw emits this repeatedly
// for one gesture (including on cancel), so the page is built once
// and cached, and the path is only mutated in `pushed`.
ctx.registry.add(nav.connectGetNextPage { _ in
guard let key = forward.last else { return nil }
if let p = pending, p.key == key { return p.page }
let entry = makeEntry(key)
pending = entry
return entry.page
})
// Commit point for a forward navigation.
ctx.registry.add(nav.connectPushed { _ in
guard let p = pending,
nav.getVisiblePage()?.getTag() == p.tag
else { return } // one of our own programmatic pushes
pending = nil
forward.removeLast()
pages.append(p)
path.write(pages.map(\.key))
})
}
}
}

View file

@ -0,0 +1,331 @@
import Foundation
import Testing
import Adw
import Gio
@_spi(SGTKInternal) import Gtk
@_spi(Portico) import Portico
@_silgen_name("g_main_context_iteration")
private nonisolated func nav_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
) -> Int32
/// Drives the GLib main context until `condition` holds or `turns` is exhausted.
@MainActor private func pump(until condition: () -> Bool, turns: Int = 200) {
for _ in 0..<turns {
if condition() { return }
_ = nav_g_main_context_iteration(nil, 0)
}
}
struct Park: Hashable, Codable { let name: String }
struct City: Hashable, Codable { let name: String }
private struct Unregistered: Hashable { let id: Int }
private func depth(_ nav: Adw.NavigationView) -> Int {
Int(nav.navigationStack.getNItems())
}
// MARK: - Probe types
/// A label that records how many times it was mounted per key.
private final class MountCounter {
var counts: [String: Int] = [:]
func inc(_ key: String) { counts[key, default: 0] += 1 }
}
private struct ProbePage: View {
let key: String
let counter: MountCounter
var body: Never { fatalError() }
init(key: String, counter: MountCounter) {
self.key = key; self.counter = counter
}
}
@_spi(Portico) extension ProbePage: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
counter.inc(key)
return Gtk.Label(str: key)
}
}
/// A shared mutable dictionary for tracking per-key subscription fires.
private final class FireCounter {
var fires: [String: Int] = [:]
func inc(_ key: String) { fires[key, default: 0] += 1 }
}
/// A page that subscribes to a StateBox so we can verify the subscription
/// is cancelled on page removal.
private struct SubscribingPage: View {
let key: String
let box: StateBox<Int>
let counter: FireCounter
var body: Never { fatalError() }
init(key: String, box: StateBox<Int>, counter: FireCounter) {
self.key = key; self.box = box; self.counter = counter
}
}
@_spi(Portico) extension SubscribingPage: Mountable {
@_spi(Portico) public func mount(_ ctx: MountContext) -> Gtk.Widget {
ctx.registry.add(box.subscribe { [key, counter] _ in
counter.inc(key)
})
return Gtk.Label(str: "sub \(key)")
}
}
// MARK: - Tests
@MainActor @Suite(.serialized) struct NavigationViewPathTests {
// MARK: Static init
@Test func staticInitMountsRootOnly() {
guard Gtk.initCheck() else { return }
let ctx = MountContext()
let view = Portico.NavigationView { Label(str: "root") }
let w = AnyView(view).makeWidget(ctx) as! Adw.NavigationView
#expect(depth(w) == 1)
#expect(w.getVisiblePage()?.getTag() == "portico.nav.root")
#expect(ctx.registry.isEmpty == true)
}
@Test func generatedNoArgInitStillMountsEmpty() {
guard Gtk.initCheck() else { return }
let ctx = MountContext()
let view = Portico.NavigationView()
let w = AnyView(view).makeWidget(ctx) as! Adw.NavigationView
#expect(depth(w) == 0)
#expect(ctx.registry.isEmpty == true)
}
// MARK: Push / pop
@Test func appendPushesDestinationPage() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let counter = MountCounter()
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
ProbePage(key: park.name, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
#expect(depth(w) == 1)
box.set([Park(name: "Yosemite")])
#expect(depth(w) == 2)
#expect(counter.counts["Yosemite"] == 1)
}
@Test func truncatePopsPage() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let counter = MountCounter()
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
ProbePage(key: park.name, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Park(name: "Yosemite"), Park(name: "Zion")])
#expect(depth(w) == 3)
box.set([Park(name: "Yosemite")])
#expect(depth(w) == 2)
}
@Test func commonPrefixSurvivesAppend() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let counter = MountCounter()
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
ProbePage(key: park.name, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Park(name: "A")])
#expect(counter.counts["A"] == 1)
box.set([Park(name: "A"), Park(name: "B")])
#expect(depth(w) == 3)
#expect(counter.counts["A"] == 1)
#expect(counter.counts["B"] == 1)
}
@Test func divergenceReplacesTailAndKeepsPrefix() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let counter = MountCounter()
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
ProbePage(key: park.name, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Park(name: "A"), Park(name: "B")])
#expect(counter.counts["A"] == 1)
#expect(counter.counts["B"] == 1)
box.set([Park(name: "A"), Park(name: "C")])
#expect(depth(w) == 3)
#expect(counter.counts["A"] == 1)
#expect(counter.counts["C"] == 1)
}
// MARK: NavigationPath
@Test func navigationPathRoutesByDynamicType() {
guard Gtk.initCheck() else { return }
let box = StateBox(NavigationPath())
let counter = MountCounter()
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
ProbePage(key: park.name, counter: counter)
}
.navigationDestination(for: City.self) { city in
ProbePage(key: city.name, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
var path = NavigationPath()
path.append(Park(name: "Yosemite"))
path.append(City(name: "Portland"))
box.set(path)
#expect(depth(w) == 3)
#expect(counter.counts["Yosemite"] == 1)
#expect(counter.counts["Portland"] == 1)
}
// MARK: Widget pop
@Test func widgetPopWritesBackToPath() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
Label(str: park.name)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Park(name: "Yosemite"), Park(name: "Zion")])
#expect(depth(w) == 3)
_ = w.pop()
pump(until: { box.get().count == 1 })
#expect(box.get().count == 1)
#expect(depth(w) == 2)
}
// MARK: Teardown
@Test func poppedPageTearsDownSubscriptions() {
guard Gtk.initCheck() else { return }
let pathBox = StateBox<[Park]>([])
let subBox = StateBox(0)
let counter = FireCounter()
let view = Portico.NavigationView(path: Binding(pathBox)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
SubscribingPage(key: park.name, box: subBox, counter: counter)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
pathBox.set([Park(name: "Yosemite")])
#expect(depth(w) == 2)
subBox.set(1)
#expect(counter.fires["Yosemite"] == 1)
pathBox.set([])
subBox.set(2)
// Teardown should have cancelled the subscription; no second fire.
#expect(counter.fires["Yosemite"] == 1)
}
// MARK: Missing destination
@Test func missingDestinationKeepsPathAligned() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Unregistered]>([])
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Unregistered(id: 1)])
#expect(depth(w) == 2)
box.set([])
#expect(depth(w) == 1)
}
// MARK: navigationTitle
@Test func navigationTitleSetsPageTitle() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
Label(str: park.name).navigationTitle(park.name)
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
box.set([Park(name: "Yosemite")])
pump(until: { w.getVisiblePage()?.getTitle() == "Yosemite" })
#expect(w.getVisiblePage()?.getTitle() == "Yosemite")
}
@Test func navigationTitleBindingUpdatesLive() {
guard Gtk.initCheck() else { return }
let pathBox = StateBox<[Park]>([])
let titleBox = StateBox("Initial")
let view = Portico.NavigationView(path: Binding(pathBox)) {
Label(str: "root")
.navigationDestination(for: Park.self) { park in
Label(str: park.name).navigationTitle(Binding(titleBox))
}
}
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
pathBox.set([Park(name: "Yosemite")])
pump(until: { w.getVisiblePage()?.getTitle() == "Initial" }, turns: 400)
#expect(w.getVisiblePage()?.getTitle() == "Initial")
titleBox.set("Renamed")
pump(until: { w.getVisiblePage()?.getTitle() == "Renamed" }, turns: 400)
#expect(w.getVisiblePage()?.getTitle() == "Renamed")
}
// MARK: Inherited modifier
@Test func inheritedModifierStillApplies() {
guard Gtk.initCheck() else { return }
let box = StateBox<[Park]>([])
let view = Portico.NavigationView(path: Binding(box)) {
Label(str: "root")
}.popOnEscape(false)
let w = AnyView(view).makeWidget(MountContext()) as! Adw.NavigationView
#expect(w.getPopOnEscape() == false)
}
// MARK: Codable
@Test func navigationPathCodableRoundTrip() throws {
var path = NavigationPath()
path.append(Park(name: "Yosemite"))
path.append(City(name: "Portland"))
let rep = try #require(path.codable)
let data = try JSONEncoder().encode(rep)
let decoded = try JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data)
let restored = NavigationPath(decoded)
#expect(restored == path)
}
@Test func navigationPathCodableNilForNonCodableElement() {
var path = NavigationPath()
path.append(Unregistered(id: 1))
#expect(path.codable == nil)
}
}