diff --git a/Gemini-iOS/BrowserNavigationController.swift b/Gemini-iOS/BrowserNavigationController.swift new file mode 100644 index 0000000..ae43239 --- /dev/null +++ b/Gemini-iOS/BrowserNavigationController.swift @@ -0,0 +1,97 @@ +// +// BrowserNavigationController.swift +// Gemini-iOS +// +// Created by Shadowfacts on 12/16/20. +// + +import UIKit +import BrowserCore +import Combine + +class BrowserNavigationController: UINavigationController { + + let navigator: NavigationManager + + var poppedViewControllers = [UIViewController]() + var skipResetPoppedOnNextPush = false + + private var interactivePushTransition: InteractivePushTransition! + + private var cancellables = [AnyCancellable]() + + init(navigator: NavigationManager) { + self.navigator = navigator + + super.init(nibName: nil, bundle: nil) + + navigator.$currentURL + .sink { [weak self] (newURL) in + self?.pushViewController(BrowserWebViewController(navigator: navigator, url: newURL), animated: true) + } + .store(in: &cancellables) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + interactivePushTransition = InteractivePushTransition(navigationController: self) + } + + override func popViewController(animated: Bool) -> UIViewController? { + if let popped = super.popViewController(animated: animated) { + poppedViewControllers.insert(popped, at: 0) + return popped + } else { + return nil + } + } + + override func popToRootViewController(animated: Bool) -> [UIViewController]? { + if let popped = super.popToRootViewController(animated: animated) { + poppedViewControllers = popped + return popped + } else { + return nil + } + } + + override func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? { + if let popped = super.popToViewController(viewController, animated: animated) { + poppedViewControllers.insert(contentsOf: popped, at: 0) + return popped + } else { + return nil + } + } + + override func pushViewController(_ viewController: UIViewController, animated: Bool) { + if skipResetPoppedOnNextPush { + skipResetPoppedOnNextPush = false + } else { + self.poppedViewControllers = [] + } + super.pushViewController(viewController, animated: animated) + } + + func onWillShow() { + self.transitionCoordinator?.notifyWhenInteractionChanges({ (context) in + if context.isCancelled { + if self.interactivePushTransition.interactive { + // when an interactive push gesture is cancelled, make sure to adding the VC that was being pushed back onto the popped stack so it doesn't disappear + self.poppedViewControllers.insert(self.interactivePushTransition.pushingViewController!, at: 0) + } else { + // when an interactive pop gesture is cancelled (i.e. the user lifts their finger before it triggers), + // the popViewController(animated:) method has already been called so the VC has already been added to the popped stack + // so we make sure to remove it, otherwise there could be duplicate VCs on the navigation stasck + self.poppedViewControllers.remove(at: 0) + } + } + }) + } + +} diff --git a/Gemini-iOS/BrowserWebViewController.swift b/Gemini-iOS/BrowserWebViewController.swift new file mode 100644 index 0000000..b72f1c2 --- /dev/null +++ b/Gemini-iOS/BrowserWebViewController.swift @@ -0,0 +1,188 @@ +// +// BrowserWebViewController.swift +// Gemini-iOS +// +// Created by Shadowfacts on 12/16/20. +// + +import UIKit +import BrowserCore +import WebKit +import GeminiProtocol +import GeminiFormat +import GeminiRenderer + +class BrowserWebViewController: UIViewController { + + let navigator: NavigationManager + let url: URL + private var task: GeminiDataTask? + private let renderer = GeminiHTMLRenderer() + + private var loaded = false + + private var errorStack: UIStackView! + private var errorMessageLabel: UILabel! + private var activityIndicator: UIActivityIndicatorView! + private var webView: WKWebView! + + init(navigator: NavigationManager, url: URL) { + self.navigator = navigator + self.url = url + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + webView = WKWebView() + webView.navigationDelegate = self + webView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(webView) + NSLayoutConstraint.activate([ + webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + webView.topAnchor.constraint(equalTo: view.topAnchor), + webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + let errorTitle = UILabel() + errorTitle.text = "An error occurred" + errorTitle.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .headline).withDesign(.serif)!, size: 0) + errorTitle.numberOfLines = 0 + errorMessageLabel = UILabel() + errorMessageLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body).withDesign(.serif)!, size: 0) + errorMessageLabel.numberOfLines = 0 + errorStack = UIStackView(arrangedSubviews: [ + errorTitle, + errorMessageLabel, + ]) + errorStack.translatesAutoresizingMaskIntoConstraints = false + errorStack.axis = .vertical + errorStack.alignment = .center + errorStack.isHidden = true + view.addSubview(errorStack) + NSLayoutConstraint.activate([ + errorStack.centerYAnchor.constraint(equalTo: view.centerYAnchor), + errorStack.leadingAnchor.constraint(equalTo: view.leadingAnchor), + errorStack.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + activityIndicator = UIActivityIndicatorView(style: .large) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + activityIndicator.isHidden = true + view.addSubview(activityIndicator) + NSLayoutConstraint.activate([ + activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), + activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + loadDocument() + } + + private func loadDocument() { + guard !loaded else { return } + + webView.isHidden = true + errorStack.isHidden = true + activityIndicator.isHidden = false + activityIndicator.startAnimating() + + let url = self.url + task = try! GeminiDataTask(url: url) { (response) in + self.task = nil + self.loaded = true + + switch response { + case let .failure(error): + DispatchQueue.main.async { + self.showError(message: error.localizedDescription) + } + case let .success(response): + if response.status.isRedirect { + if let redirect = URL(string: response.meta) { + self.navigator.changeURL(redirect) + } else { + DispatchQueue.main.async { + self.showError(message: "Invalid redirect URL: '\(response.meta)'") + } + } + } else if response.status.isSuccess, + let text = response.bodyText { + self.renderDocument(GeminiParser.parse(text: text, baseURL: url)) + } else { + DispatchQueue.main.async { + self.showError(message: "Unknown error: \(response.header)") + } + } + } + } + task!.resume() + } + + private func showError(message: String) { + webView.isHidden = true + errorStack.isHidden = false + activityIndicator.isHidden = true + activityIndicator.stopAnimating() + + errorMessageLabel.text = message + } + + private func renderDocument(_ doc: Document) { + let html = BrowserWebViewController.preamble + renderer.renderDocumentToHTML(doc) + BrowserWebViewController.postamble + DispatchQueue.main.async { + self.webView.isHidden = false + self.errorStack.isHidden = true + self.activityIndicator.isHidden = true + self.activityIndicator.stopAnimating() + + if let title = doc.title { + self.navigationItem.title = title + } + + self.webView.loadHTMLString(html, baseURL: Bundle.main.bundleURL) + } + } + + private static let preamble = """ + + + + + + + + + """ + + private static let postamble = """ + + + """ + +} + +extension BrowserWebViewController: WKNavigationDelegate { + func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + let url = navigationAction.request.url! + if url.scheme == "file" { + decisionHandler(.allow) + } else { + decisionHandler(.cancel) + navigator.changeURL(url) + loadDocument() + } + } +} diff --git a/Gemini-iOS/InteractivePushTransition.swift b/Gemini-iOS/InteractivePushTransition.swift new file mode 100644 index 0000000..3fc9eec --- /dev/null +++ b/Gemini-iOS/InteractivePushTransition.swift @@ -0,0 +1,142 @@ +// +// InteractivePushTransition.swift +// Gemini-iOS +// +// Created by Shadowfacts on 12/16/20. +// + +import UIKit + +/// Allows interactively moving forward through the navigation stack after popping +/// Based on https://github.com/NSExceptional/TBInteractivePushTransition +class InteractivePushTransition: UIPercentDrivenInteractiveTransition { + + fileprivate let minimumPushVelocityThreshold: CGFloat = 700 + fileprivate let minimumPushDistanceThreshold: CGFloat = 0.5 + fileprivate let pushAnimationDuration: TimeInterval = 0.35 + + private(set) weak var navigationController: BrowserNavigationController! + + private(set) weak var interactivePushGestureRecognizer: UIScreenEdgePanGestureRecognizer! + + private(set) var interactive = false + private(set) weak var pushingViewController: UIViewController? + + init(navigationController: BrowserNavigationController) { + super.init() + + self.navigationController = navigationController + + let interactivePushGestureRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleSwipeForward(_:))) + self.interactivePushGestureRecognizer = interactivePushGestureRecognizer + + navigationController.delegate = self + interactivePushGestureRecognizer.edges = .right + interactivePushGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!) + navigationController.view.addGestureRecognizer(interactivePushGestureRecognizer) + + let trackpadGestureRecognizer = TrackpadScrollGestureRecognizer(target: self, action: #selector(handleSwipeForward(_:))) + trackpadGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!) + navigationController.view.addGestureRecognizer(trackpadGestureRecognizer) + } + + @objc func handleSwipeForward(_ recognizer: UIPanGestureRecognizer) { + interactive = true + + let velocity = recognizer.velocity(in: recognizer.view) + let translation = recognizer.translation(in: recognizer.view) + let dx = -translation.x / navigationController.view.bounds.width + let vx = -velocity.x + + switch recognizer.state { + case .began: + if let viewController = navigationController.poppedViewControllers.first { + pushingViewController = viewController + navigationController.poppedViewControllers.removeFirst() + navigationController.skipResetPoppedOnNextPush = true + navigationController.pushViewController(viewController, animated: true) + } else { + interactive = false + } + + case .changed: + update(dx) + + case .ended: + if (dx > minimumPushDistanceThreshold || vx > minimumPushVelocityThreshold) { + finish() + } else { + cancel() + } + interactive = false + + default: + cancel() + interactive = false + } + } + +} + +extension InteractivePushTransition: UINavigationControllerDelegate { + func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) { + self.navigationController.onWillShow() + } + + func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? { + if operation == .push, interactive { + return self + } + + return nil + } + + func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? { + if interactive { + return self + } + return nil + } +} + +extension InteractivePushTransition: UIViewControllerAnimatedTransitioning { + func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval { + return pushAnimationDuration + } + + func animateTransition(using transitionContext: UIViewControllerContextTransitioning) { + let toView = transitionContext.view(forKey: .to)! + let fromView = transitionContext.view(forKey: .from)! + + let dimmingView = UIView() + dimmingView.backgroundColor = .black + dimmingView.alpha = 0 + dimmingView.frame = fromView.frame + + transitionContext.containerView.addSubview(dimmingView) + transitionContext.containerView.addSubview(toView) + + // modify frame of presented view to go from x = to x = 0 + var frame = fromView.frame + frame.origin.x = frame.width + toView.frame = frame + frame.origin.x = 0 + + // we want it linear while interactive, but we want the "ease out" animation + // if the user flicks the screen ahrd enough to finish the transition without interaction + let options = interactive ? UIView.AnimationOptions.curveLinear : .curveEaseOut + + let duration = transitionDuration(using: transitionContext) + UIView.animate(withDuration: duration, delay: 0, options: options, animations: { + // these magic numbers scientifically determined by repeatedly adjusting and comparing to the system animation + let translationDistance = -frame.size.width * 0.3 + fromView.transform = CGAffineTransform(translationX: translationDistance, y: 0) + toView.frame = frame + dimmingView.alpha = 0.075 + }) { (finished) in + fromView.transform = .identity + dimmingView.removeFromSuperview() + transitionContext.completeTransition(!transitionContext.transitionWasCancelled) + } + } +} diff --git a/Gemini-iOS/Resources/browser.css b/Gemini-iOS/Resources/browser.css new file mode 100644 index 0000000..d4951ee --- /dev/null +++ b/Gemini-iOS/Resources/browser.css @@ -0,0 +1,40 @@ +body { + font-family: ui-serif; + overflow-wrap: break-word; +} + +pre { + font-family: ui-monospace; + overflow-wrap: normal; + overflow-x: auto; + tab-size: 4; +} + +h1, h2, h3 { + line-height: 1; + font-weight: normal; +} + +p.link { + display: block; + margin: 8px 0; +} + +a { + color: rgb(0, 122, 255); +} + +ul { + padding-left: 20px; +} + +@media (prefers-color-scheme: dark) { + body { + background-color: black; + color: white; + } + + a { + color: rgb(10, 132, 255); + } +} diff --git a/Gemini-iOS/SceneDelegate.swift b/Gemini-iOS/SceneDelegate.swift index d7b9eb1..356a692 100644 --- a/Gemini-iOS/SceneDelegate.swift +++ b/Gemini-iOS/SceneDelegate.swift @@ -40,13 +40,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { navigationManager.delegate = self // Create the SwiftUI view that provides the window contents. - let contentView = ContentView(navigator: navigationManager) +// let contentView = ContentView(navigator: navigationManager) // Use a UIHostingController as window root view controller. if let windowScene = scene as? UIWindowScene { let window = UIWindow(windowScene: windowScene) window.overrideUserInterfaceStyle = Preferences.shared.theme - window.rootViewController = UIHostingController(rootView: contentView) + window.rootViewController = BrowserNavigationController(navigator: navigationManager) +// window.rootViewController = UIHostingController(rootView: contentView) // window.rootViewController = BrowserViewController(navigator: navigationManager) self.window = window window.makeKeyAndVisible() diff --git a/Gemini-iOS/TrackpadScrollGestureRecognizer.swift b/Gemini-iOS/TrackpadScrollGestureRecognizer.swift new file mode 100644 index 0000000..d9f361a --- /dev/null +++ b/Gemini-iOS/TrackpadScrollGestureRecognizer.swift @@ -0,0 +1,22 @@ +// +// TrackpadScrollGestureRecognizer.swift +// Gemini-iOS +// +// Created by Shadowfacts on 12/16/20. +// + +import UIKit + +class TrackpadScrollGestureRecognizer: UIPanGestureRecognizer { + + override init(target: Any?, action: Selector?) { + super.init(target: target, action: action) + + self.allowedScrollTypesMask = .all + } + + override func shouldReceive(_ event: UIEvent) -> Bool { + return event.type == .scroll + } + +} diff --git a/Gemini.xcodeproj/project.pbxproj b/Gemini.xcodeproj/project.pbxproj index 3160f69..8570c16 100644 --- a/Gemini.xcodeproj/project.pbxproj +++ b/Gemini.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 52; objects = { /* Begin PBXBuildFile section */ @@ -40,6 +40,13 @@ D664673624BD07F700B0B741 /* RenderingBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673524BD07F700B0B741 /* RenderingBlock.swift */; }; D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673724BD086F00B0B741 /* RenderingBlockView.swift */; }; D664673A24BD0B8E00B0B741 /* Fonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673924BD0B8E00B0B741 /* Fonts.swift */; }; + D688F586258AC738003A0A73 /* GeminiHTMLRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */; }; + D688F590258AC814003A0A73 /* HTMLEntities in Frameworks */ = {isa = PBXBuildFile; productRef = D688F58F258AC814003A0A73 /* HTMLEntities */; }; + D688F599258ACAAE003A0A73 /* BrowserWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */; }; + D688F5FF258ACE6B003A0A73 /* browser.css in Resources */ = {isa = PBXBuildFile; fileRef = D688F5FE258ACE6B003A0A73 /* browser.css */; }; + D688F621258B0811003A0A73 /* BrowserNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F620258B0811003A0A73 /* BrowserNavigationController.swift */; }; + D688F62A258B0833003A0A73 /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F629258B0833003A0A73 /* InteractivePushTransition.swift */; }; + D688F633258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */; }; D691A64E25217C6F00348C4B /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A64D25217C6F00348C4B /* Preferences.swift */; }; D691A66725217FD800348C4B /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A66625217FD800348C4B /* PreferencesView.swift */; }; D691A68725223A4700348C4B /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A68625223A4600348C4B /* NavigationBar.swift */; }; @@ -298,6 +305,12 @@ D664673524BD07F700B0B741 /* RenderingBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlock.swift; sourceTree = ""; }; D664673724BD086F00B0B741 /* RenderingBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlockView.swift; sourceTree = ""; }; D664673924BD0B8E00B0B741 /* Fonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fonts.swift; sourceTree = ""; }; + D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiHTMLRenderer.swift; sourceTree = ""; }; + D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWebViewController.swift; sourceTree = ""; }; + D688F5FE258ACE6B003A0A73 /* browser.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = browser.css; sourceTree = ""; }; + D688F620258B0811003A0A73 /* BrowserNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserNavigationController.swift; sourceTree = ""; }; + D688F629258B0833003A0A73 /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = ""; }; + D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = ""; }; D691A64D25217C6F00348C4B /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; D691A66625217FD800348C4B /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; D691A6762522382E00348C4B /* BrowserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserViewController.swift; sourceTree = ""; }; @@ -373,6 +386,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D688F590258AC814003A0A73 /* HTMLEntities in Frameworks */, D62664F024BC0D7700DF9B88 /* GeminiFormat.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -530,6 +544,7 @@ D62664EB24BC0B4D00DF9B88 /* DocumentView.swift */, D664673724BD086F00B0B741 /* RenderingBlockView.swift */, D6DA5782252396030048B65A /* View+Extensions.swift */, + D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */, ); path = GeminiRenderer; sourceTree = ""; @@ -550,11 +565,23 @@ name = Frameworks; sourceTree = ""; }; + D688F618258AD231003A0A73 /* Resources */ = { + isa = PBXGroup; + children = ( + D688F5FE258ACE6B003A0A73 /* browser.css */, + ); + path = Resources; + sourceTree = ""; + }; D6E152A324BFFDF500FDF9D3 /* Gemini-iOS */ = { isa = PBXGroup; children = ( D6E152A424BFFDF500FDF9D3 /* AppDelegate.swift */, D6E152A624BFFDF500FDF9D3 /* SceneDelegate.swift */, + D688F620258B0811003A0A73 /* BrowserNavigationController.swift */, + D688F629258B0833003A0A73 /* InteractivePushTransition.swift */, + D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */, + D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */, D691A6762522382E00348C4B /* BrowserViewController.swift */, D6E152A824BFFDF500FDF9D3 /* ContentView.swift */, D691A68625223A4600348C4B /* NavigationBar.swift */, @@ -562,6 +589,7 @@ D691A64D25217C6F00348C4B /* Preferences.swift */, D691A66625217FD800348C4B /* PreferencesView.swift */, D62BCEE1252553620031D894 /* ActivityView.swift */, + D688F618258AD231003A0A73 /* Resources */, D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */, D6E152AF24BFFDF600FDF9D3 /* LaunchScreen.storyboard */, D6E152B224BFFDF600FDF9D3 /* Info.plist */, @@ -748,6 +776,9 @@ D68544302522E10F004C4AE0 /* PBXTargetDependency */, ); name = GeminiRenderer; + packageProductDependencies = ( + D688F58F258AC814003A0A73 /* HTMLEntities */, + ); productName = GeminiRenderer; productReference = D62664CE24BC081B00DF9B88 /* GeminiRenderer.framework */; productType = "com.apple.product-type.framework"; @@ -890,6 +921,9 @@ Base, ); mainGroup = D626645224BBF1C200DF9B88; + packageReferences = ( + D688F58E258AC814003A0A73 /* XCRemoteSwiftPackageReference "swift-html-entities" */, + ); productRefGroup = D626645C24BBF1C200DF9B88 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -968,6 +1002,7 @@ files = ( D6E152B124BFFDF600FDF9D3 /* LaunchScreen.storyboard in Resources */, D6E152AE24BFFDF600FDF9D3 /* Preview Assets.xcassets in Resources */, + D688F5FF258ACE6B003A0A73 /* browser.css in Resources */, D6E152AB24BFFDF600FDF9D3 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1048,6 +1083,7 @@ D62664EE24BC0BCE00DF9B88 /* MaybeLazyVStack.swift in Sources */, D62664EC24BC0B4D00DF9B88 /* DocumentView.swift in Sources */, D6DA5783252396030048B65A /* View+Extensions.swift in Sources */, + D688F586258AC738003A0A73 /* GeminiHTMLRenderer.swift in Sources */, D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1064,7 +1100,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D688F621258B0811003A0A73 /* BrowserNavigationController.swift in Sources */, D691A66725217FD800348C4B /* PreferencesView.swift in Sources */, + D688F62A258B0833003A0A73 /* InteractivePushTransition.swift in Sources */, + D688F599258ACAAE003A0A73 /* BrowserWebViewController.swift in Sources */, + D688F633258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift in Sources */, D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */, D691A6A0252242FC00348C4B /* ToolBar.swift in Sources */, D6E152A724BFFDF500FDF9D3 /* SceneDelegate.swift in Sources */, @@ -1949,6 +1989,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D688F58E258AC814003A0A73 /* XCRemoteSwiftPackageReference "swift-html-entities" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Kitura/swift-html-entities"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.200; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D688F58F258AC814003A0A73 /* HTMLEntities */ = { + isa = XCSwiftPackageProductDependency; + package = D688F58E258AC814003A0A73 /* XCRemoteSwiftPackageReference "swift-html-entities" */; + productName = HTMLEntities; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = D626645324BBF1C200DF9B88 /* Project object */; } diff --git a/Gemini.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Gemini.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..14af2fa --- /dev/null +++ b/Gemini.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "HTMLEntities", + "repositoryURL": "https://github.com/Kitura/swift-html-entities", + "state": { + "branch": null, + "revision": "2b14531d0c36dbb7c1c45a4d38db9c2e7898a307", + "version": "3.0.200" + } + } + ] + }, + "version": 1 +} diff --git a/GeminiFormat/Document.swift b/GeminiFormat/Document.swift index 34864ee..8c6c0e8 100644 --- a/GeminiFormat/Document.swift +++ b/GeminiFormat/Document.swift @@ -21,6 +21,13 @@ public struct Document { // todo: should this be \r\n return lines.map { $0.geminiText() }.joined(separator: "\n") } + + public var title: String? { + for case let .heading(text, level: _) in lines { + return text + } + return nil + } } public extension Document { diff --git a/GeminiRenderer/GeminiHTMLRenderer.swift b/GeminiRenderer/GeminiHTMLRenderer.swift new file mode 100644 index 0000000..c29d629 --- /dev/null +++ b/GeminiRenderer/GeminiHTMLRenderer.swift @@ -0,0 +1,78 @@ +// +// GeminiHTMLRenderer.swift +// GeminiRenderer +// +// Created by Shadowfacts on 12/16/20. +// + +import Foundation +import GeminiFormat +import HTMLEntities + +public protocol GeminiHTMLRendererDelegate: class { + +} + +public class GeminiHTMLRenderer { + + public weak var delegate: GeminiHTMLRendererDelegate? + + public init() { + } + + public func renderDocumentToHTML(_ doc: Document) -> String { + var str = "" + + var inPreformatting = false + var inList = false + + for line in doc.lines { + if inList && !line.isListItem { + str += "" + } + + switch line { + case let .text(text): + str += "

\(text.htmlEscape())

" + case let .link(url, text: maybeText): + let text = maybeText ?? url.absoluteString + str += "

\(text.htmlEscape())

" + case .preformattedToggle(alt: _): + inPreformatting = !inPreformatting + if inPreformatting { + str += "
"
+                } else {
+                    str += "
" + } + case let .preformattedText(text): + str += text.htmlEscape() + str += "\n" + case let .heading(text, level: level): + let tag = "h\(level.rawValue)" + str += "<\(tag)>\(text.htmlEscape())" + case let .unorderedListItem(text): + if !inList { + inList = true + str += "
    " + } + str += "
  • \(text.htmlEscape())
  • " + case let .quote(text): + str += "
    \(text.htmlEscape())
    " + } + } + + return str + } + +} + +fileprivate extension Document.Line { + var isListItem: Bool { + switch self { + case .unorderedListItem(_): + return true + default: + return false + } + } +}