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())\(tag)>"
+ 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
+ }
+ }
+}