Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ A modern, type-safe navigation library built entirely on SwiftUI's `NavigationSt
- **Gesture sync** — swipe-back updates the pilot's stack automatically via a `Binding`
- **No UIKit** — built entirely on `NavigationStack` and SwiftUI state

### 🛠 Debugging
- **Native logging** — pass `debug: true` to `NavPilot` to enable internal `OSLog`-based navigation logs

### 🌿 Environment Injection
- **`@EnvironmentObject`** — every child view receives the pilot automatically
- **No prop drilling** — navigate from anywhere in the view hierarchy
Expand Down
64 changes: 53 additions & 11 deletions Sources/NavPilot/NavPilot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Combine
/// `T` must be `Hashable` so NavigationStack can drive itself.
@MainActor
public final class NavPilot<T: Hashable>: ObservableObject {
private let debug: Bool

/// The live navigation stack. Index 0 is always the root.
@Published public private(set) var stack: [T]
Expand All @@ -25,74 +26,115 @@ public final class NavPilot<T: Hashable>: ObservableObject {
public var depth: Int { stack.count }

/// Initialize with a root route.
public init(initial: T) {
public init(initial: T, debug: Bool = false) {
self.debug = debug
self.stack = [initial]
NavPilotLogger.log(enabled: debug, "init \(stackDescription())")
}

// ── Push ──────────────────────────────────────────────────

/// Push one route onto the stack.
public func push(_ route: T) {
stack.append(route)
NavPilotLogger.log(enabled: debug, "push \(describe(route)) -> \(stackDescription())")
}

/// Push multiple routes at once (pushed in the order given).
public func push(_ routes: T...) {
guard !routes.isEmpty else {
NavPilotLogger.log(enabled: debug, "push ignored: no routes -> \(stackDescription())")
return
}
stack.append(contentsOf: routes)
NavPilotLogger.log(enabled: debug, "push \(routes.map(describe).joined(separator: ", ")) -> \(stackDescription())")
}

// ── Pop ───────────────────────────────────────────────────

/// Pop the top route. No-op if already at root.
public func pop() {
guard stack.count > 1 else { return }
guard stack.count > 1 else {
NavPilotLogger.log(enabled: debug, "pop ignored at root -> \(stackDescription())")
return
}
stack.removeLast()
NavPilotLogger.log(enabled: debug, "pop -> \(stackDescription())")
}

/// Pop `n` routes at once. Always keeps the root.
public func pop(count n: Int) {
let removeCount = min(n, stack.count - 1)
guard removeCount > 0 else { return }
guard removeCount > 0 else {
NavPilotLogger.log(enabled: debug, "pop(count: \(n)) ignored -> \(stackDescription())")
return
}
stack.removeLast(removeCount)
NavPilotLogger.log(enabled: debug, "pop(count: \(n)) -> \(stackDescription())")
}

/// Pop back to the first occurrence of `route`.
/// Stack is unchanged if `route` is not found.
public func popTo(_ route: T) {
guard let idx = stack.firstIndex(of: route) else { return }
guard let idx = stack.firstIndex(of: route) else {
NavPilotLogger.log(enabled: debug, "popTo \(describe(route)) ignored (not found) -> \(stackDescription())")
return
}
stack = Array(stack.prefix(through: idx))
NavPilotLogger.log(enabled: debug, "popTo \(describe(route)) -> \(stackDescription())")
}

/// Pop everything back to the root.
public func popToRoot() {
guard let root = stack.first else { return }
guard let root = stack.first else {
NavPilotLogger.log(enabled: debug, "popToRoot ignored -> []")
return
}
stack = [root]
NavPilotLogger.log(enabled: debug, "popToRoot -> \(stackDescription())")
}

// ── Replace ───────────────────────────────────────────────

/// Replace the entire stack. The first element becomes the new root.
public func replace(_ routes: [T]) {
guard !routes.isEmpty else { return }
guard !routes.isEmpty else {
NavPilotLogger.log(enabled: debug, "replace ignored: [] -> \(stackDescription())")
return
}
stack = routes
NavPilotLogger.log(enabled: debug, "replace -> \(stackDescription())")
}

/// Swap only the top-most route.
public func replaceCurrent(with route: T) {
guard !stack.isEmpty else { return }
guard !stack.isEmpty else {
NavPilotLogger.log(enabled: debug, "replaceCurrent \(describe(route)) ignored -> []")
return
}
stack[stack.count - 1] = route
NavPilotLogger.log(enabled: debug, "replaceCurrent \(describe(route)) -> \(stackDescription())")
}

// ── Internal ──────────────────────────────────────────────

/// Called by NavPilotHost to sync the stack after a native swipe-back.
func syncTail(_ tail: [T]) {
guard let root = stack.first else { return }
guard let root = stack.first else {
NavPilotLogger.log(enabled: debug, "syncTail ignored -> []")
return
}
stack = [root] + tail
NavPilotLogger.log(enabled: debug, "syncTail -> \(stackDescription())")
}
}



private func describe(_ route: T) -> String {
String(describing: route)
}

private func stackDescription(_ routes: [T]? = nil) -> String {
let values = (routes ?? stack).map(describe)
return "[" + values.joined(separator: " -> ") + "]"
}
}

36 changes: 36 additions & 0 deletions Sources/NavPilot/NavPilotLogger.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// NavPilotLogger.swift
// NavPilot
//
// Created by DK on 14/05/26.
//

import Foundation
import OSLog

@MainActor
internal enum NavPilotLogger {
private static let logger = Logger(subsystem: "NavPilot", category: "Navigation")
private static var testSink: ((String) -> Void)?

internal static func log(enabled: Bool, _ message: @autoclosure () -> String) {
guard enabled else { return }
let resolvedMessage = message()
testSink?(resolvedMessage)
logger.debug("\(resolvedMessage, privacy: .public)")
}

internal static func withTestSink<R>(
_ sink: @escaping (String) -> Void,
perform work: () throws -> R
) rethrows -> R {
let previous = testSink
testSink = sink

defer {
testSink = previous
}

return try work()
}
}
47 changes: 47 additions & 0 deletions Tests/NavPilotTests/NavPilotTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,51 @@ struct NavPilotTests {
#expect(pilot.stack == [.home, .detail(id: 42)])
#expect(pilot.current == .detail(id: 42))
}

@Test func emitsDebugLogsForNavigationActions() async throws {
var messages: [String] = []
NavPilotLogger.withTestSink({ messages.append($0) }) {
let pilot = NavPilot(initial: TestRoute.home, debug: true)

pilot.push(.detail(id: 1))
pilot.pop()
pilot.replaceCurrent(with: .settings)
}

#expect(messages == [
"init [home]",
"push detail(id: 1) -> [home -> detail(id: 1)]",
"pop -> [home]",
"replaceCurrent settings -> [settings]"
])
}

@Test func emitsDebugLogsForNoopActions() async throws {
var messages: [String] = []
NavPilotLogger.withTestSink({ messages.append($0) }) {
let pilot = NavPilot(initial: TestRoute.home, debug: true)

pilot.pop()
pilot.popTo(.settings)
pilot.replace([])
}

#expect(messages == [
"init [home]",
"pop ignored at root -> [home]",
"popTo settings ignored (not found) -> [home]",
"replace ignored: [] -> [home]"
])
}

@Test func staysSilentByDefault() async throws {
var messages: [String] = []
NavPilotLogger.withTestSink({ messages.append($0) }) {
let pilot = NavPilot(initial: TestRoute.home)
pilot.push(.detail(id: 1))
pilot.pop()
}

#expect(messages.isEmpty)
}
}
Loading