// // NavigationManager.swift // Gemini // // Created by Shadowfacts on 7/14/20. // import Foundation import Combine public protocol NavigationManagerDelegate: AnyObject { func loadNonGeminiURL(_ url: URL) } public class NavigationManager: NSObject, ObservableObject, Codable { public weak var delegate: NavigationManagerDelegate? @Published public var currentURL: URL @Published public var backStack = [HistoryEntry]() @Published public var forwardStack = [HistoryEntry]() public let navigationOperation = PassthroughSubject() public var displayURL: String { var components = URLComponents(url: currentURL, resolvingAgainstBaseURL: false)! if components.port == 1965 { components.port = nil } return components.string! } private var currentHistoryEntry: HistoryEntry public init(url: URL) { self.currentURL = url self.currentHistoryEntry = HistoryEntry(url: url, title: nil) } public required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.currentHistoryEntry = try container.decode(HistoryEntry.self, forKey: .currentHistoryEntry) self.currentURL = self.currentHistoryEntry.url self.backStack = try container.decode([HistoryEntry].self, forKey: .backStack) self.forwardStack = try container.decode([HistoryEntry].self, forKey: .forwardStack) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(currentHistoryEntry, forKey: .currentHistoryEntry) try container.encode(backStack, forKey: .backStack) try container.encode(forwardStack, forKey: .forwardStack) } public func setTitleForCurrentURL(_ title: String?) { currentHistoryEntry.title = title } public func changeURL(_ url: URL) { var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! if let scheme = url.scheme { if scheme != "gemini" { delegate?.loadNonGeminiURL(url) return } } else { components.scheme = "gemini" } // Foundation parses bare hosts (e.g. `example.com`) as having no host and a path of `example.com` if components.host == nil { components.host = components.path components.path = "/" } // Some Gemini servers break on empty paths if components.path.isEmpty { components.path = "/" } let url = components.url! backStack.append(currentHistoryEntry) currentURL = url currentHistoryEntry = HistoryEntry(url: url, title: nil) forwardStack = [] navigationOperation.send(.go) } @objc public func reload() { let url = currentURL currentURL = url navigationOperation.send(.reload) } @objc public func goBack() { back(count: 1) } public func back(count: Int) { guard count <= backStack.count else { return } var removed = backStack.suffix(count) backStack.removeLast(count) forwardStack.insert(currentHistoryEntry, at: 0) currentHistoryEntry = removed.removeFirst() currentURL = currentHistoryEntry.url forwardStack.insert(contentsOf: removed, at: 0) navigationOperation.send(.backward(count: count)) } @objc public func goForward() { forward(count: 1) } public func forward(count: Int) { guard count <= forwardStack.count else { return } var removed = forwardStack.prefix(count) forwardStack.removeFirst(count) backStack.append(currentHistoryEntry) currentHistoryEntry = removed.removeLast() currentURL = currentHistoryEntry.url backStack.append(contentsOf: removed) navigationOperation.send(.forward(count: count)) } } extension NavigationManager { enum CodingKeys: String, CodingKey { case currentHistoryEntry case backStack case forwardStack } } public extension NavigationManager { enum Operation { case go, reload, forward(count: Int), backward(count: Int) } } public extension NavigationManager { struct HistoryEntry: Codable { public let url: URL public internal(set) var title: String? } }