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( 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, @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 } } // `.disabled` because the body mounts subtrees - see // `DependencyTracker`'s doc comment. let tracker = DependencyTracker(observation: .disabled) { 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)) }) } } }