Compare commits
43 Commits
ac66feadcc
...
7253b1218a
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 7253b1218a | |
Shadowfacts | bb16c9ca9f | |
Shadowfacts | 7506ff3225 | |
Shadowfacts | 5e2af7d678 | |
Shadowfacts | a8a8ea10a1 | |
Shadowfacts | d2f3ddf864 | |
Shadowfacts | 71831e58f2 | |
Shadowfacts | e27ac15635 | |
Shadowfacts | 040a799b7b | |
Shadowfacts | 92fe14cd9b | |
Shadowfacts | d4af73a18e | |
Shadowfacts | a0a12f3865 | |
Shadowfacts | 444c4c053d | |
Shadowfacts | f647402a41 | |
Shadowfacts | 43b105c85e | |
Shadowfacts | f2b78d676f | |
Shadowfacts | d8b1c4d9c6 | |
Shadowfacts | a3af047591 | |
Shadowfacts | be2e087a9e | |
Shadowfacts | 2b06a826ae | |
Shadowfacts | 1454e9dc01 | |
Shadowfacts | d3c196949e | |
Shadowfacts | 256cb0958e | |
Shadowfacts | 7c1a8aa2f5 | |
Shadowfacts | abb80df9a7 | |
Shadowfacts | 314d8cf82c | |
Shadowfacts | 2d60f733c3 | |
Shadowfacts | 9d1d8828a0 | |
Shadowfacts | 203bd1804f | |
Shadowfacts | 364ffe9f94 | |
Shadowfacts | 4f3e1432e7 | |
Shadowfacts | 89b226e321 | |
Shadowfacts | 107c4b0d72 | |
Shadowfacts | 83dad76b82 | |
Shadowfacts | b000f1c2b3 | |
Shadowfacts | 01a3eaf17f | |
Shadowfacts | 8a895b70c8 | |
Shadowfacts | 19848ba8e4 | |
Shadowfacts | 182bb4b79b | |
Shadowfacts | 71b6352395 | |
Shadowfacts | 1449dc215b | |
Shadowfacts | 012ada4af7 | |
Shadowfacts | 57023d204d |
|
@ -39,6 +39,7 @@ public struct BrowserView: View {
|
||||||
Text("An error occurred")
|
Text("An error occurred")
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(message)
|
Text(message)
|
||||||
|
.lineLimit(nil)
|
||||||
case let .document(doc):
|
case let .document(doc):
|
||||||
DocumentView(document: doc, scrollingEnabled: scrollingEnabled, changeURL: navigator.changeURL)
|
DocumentView(document: doc, scrollingEnabled: scrollingEnabled, changeURL: navigator.changeURL)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
public protocol NavigationManagerDelegate: class {
|
public protocol NavigationManagerDelegate: class {
|
||||||
func loadNonGeminiURL(_ url: URL)
|
func loadNonGeminiURL(_ url: URL)
|
||||||
|
@ -19,6 +20,16 @@ public class NavigationManager: NSObject, ObservableObject {
|
||||||
@Published public var backStack = [URL]()
|
@Published public var backStack = [URL]()
|
||||||
@Published public var forwardStack = [URL]()
|
@Published public var forwardStack = [URL]()
|
||||||
|
|
||||||
|
public let navigationOperation = PassthroughSubject<Operation, Never>()
|
||||||
|
|
||||||
|
public var displayURL: String {
|
||||||
|
var components = URLComponents(url: currentURL, resolvingAgainstBaseURL: false)!
|
||||||
|
if components.port == 1965 {
|
||||||
|
components.port = nil
|
||||||
|
}
|
||||||
|
return components.string!
|
||||||
|
}
|
||||||
|
|
||||||
public init(url: URL) {
|
public init(url: URL) {
|
||||||
self.currentURL = url
|
self.currentURL = url
|
||||||
}
|
}
|
||||||
|
@ -30,9 +41,6 @@ public class NavigationManager: NSObject, ObservableObject {
|
||||||
delegate?.loadNonGeminiURL(url)
|
delegate?.loadNonGeminiURL(url)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if components.port == 1965 {
|
|
||||||
components.port = nil
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
components.scheme = "gemini"
|
components.scheme = "gemini"
|
||||||
}
|
}
|
||||||
|
@ -43,28 +51,60 @@ public class NavigationManager: NSObject, ObservableObject {
|
||||||
components.path = "/"
|
components.path = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some Gemini servers break on empty paths
|
||||||
|
if components.path.isEmpty {
|
||||||
|
components.path = "/"
|
||||||
|
}
|
||||||
|
|
||||||
let url = components.url!
|
let url = components.url!
|
||||||
|
|
||||||
backStack.append(currentURL)
|
backStack.append(currentURL)
|
||||||
currentURL = url
|
currentURL = url
|
||||||
forwardStack = []
|
forwardStack = []
|
||||||
|
|
||||||
|
navigationOperation.send(.go)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func reload() {
|
@objc public func reload() {
|
||||||
let url = currentURL
|
let url = currentURL
|
||||||
currentURL = url
|
currentURL = url
|
||||||
|
navigationOperation.send(.reload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public func back() {
|
@objc public func goBack() {
|
||||||
guard !backStack.isEmpty else { return }
|
back(count: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func back(count: Int) {
|
||||||
|
guard count <= backStack.count else { return }
|
||||||
|
var removed = backStack.suffix(count)
|
||||||
|
backStack.removeLast(count)
|
||||||
forwardStack.insert(currentURL, at: 0)
|
forwardStack.insert(currentURL, at: 0)
|
||||||
currentURL = backStack.removeLast()
|
currentURL = removed.removeFirst()
|
||||||
|
forwardStack.insert(contentsOf: removed, at: 0)
|
||||||
|
|
||||||
|
navigationOperation.send(.backward(count: count))
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc public func forward() {
|
@objc public func goForward() {
|
||||||
guard !forwardStack.isEmpty else { return }
|
forward(count: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func forward(count: Int) {
|
||||||
|
guard count <= forwardStack.count else { return }
|
||||||
|
var removed = forwardStack.prefix(count)
|
||||||
|
forwardStack.removeFirst(count)
|
||||||
backStack.append(currentURL)
|
backStack.append(currentURL)
|
||||||
currentURL = forwardStack.removeFirst()
|
currentURL = removed.removeLast()
|
||||||
|
backStack.append(contentsOf: removed)
|
||||||
|
|
||||||
|
navigationOperation.send(.forward(count: count))
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public extension NavigationManager {
|
||||||
|
enum Operation {
|
||||||
|
case go, reload, forward(count: Int), backward(count: Int)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
# 2021.1 (5)
|
||||||
|
This is a major update, as the UI code has been rewritten completely from scratch and is much more pleasant to use!
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Add forwards/backwards navigation edge swipe gestures
|
||||||
|
- Add Table of Contents feature
|
||||||
|
- Shows hierarchy of headings on the page
|
||||||
|
- Allows tapping a heading to jump to its position
|
||||||
|
- Display images served over Gemini
|
||||||
|
- Hide system status bar when UI chrome is hidden
|
||||||
|
- Add text selection support
|
||||||
|
- Add context menu previews for Gemini and HTTP(S) links
|
||||||
|
- Be more lenient when interpreting URLs typed into the URL bar
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix redirects not being followed
|
||||||
|
- Fix keyboard not being dismissed in some circumstances
|
||||||
|
- Warn on tapping a non-Gemini/HTTP/HTTPS link that cannot be handled by any installed app
|
||||||
|
- iPadOS: Fix incorrect Preferences view style
|
||||||
|
|
||||||
|
# 2020.1 (4)
|
||||||
|
This is a quick build just to fix a couple issues. The next version (barring any major problems with this version) is going to take more time, as I'm planning to significantly rework the UI code.
|
||||||
|
|
||||||
|
Known Issues:
|
||||||
|
- Document text is not accessible to VoiceOver/Voice Control
|
||||||
|
|
||||||
|
Features/Improvements:
|
||||||
|
- Add history context menus to Back/Forward buttons
|
||||||
|
- Add theme override preference
|
||||||
|
|
||||||
|
Bugfixes:
|
||||||
|
- Fix crash tapping Share button on iPad
|
||||||
|
- Add accessibility labels to toolbar buttons
|
||||||
|
- Improve link contrast in dark mode
|
||||||
|
- Add pointer interactions to toolbar buttons
|
|
@ -13,7 +13,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||||
|
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
||||||
// Override point for customization after application launch.
|
SymbolCache.load()
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,367 @@
|
||||||
|
//
|
||||||
|
// BrowserNavigationController.swift
|
||||||
|
// Gemini-iOS
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/17/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import BrowserCore
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
class BrowserNavigationController: UIViewController {
|
||||||
|
|
||||||
|
let navigator: NavigationManager
|
||||||
|
|
||||||
|
private var backBrowserVCs = [BrowserWebViewController]()
|
||||||
|
private var forwardBrowserVCs = [BrowserWebViewController]()
|
||||||
|
private var currentBrowserVC: BrowserWebViewController!
|
||||||
|
|
||||||
|
private var browserContainer: UIView!
|
||||||
|
private var navBarView: NavigationBarView!
|
||||||
|
private var toolbarView: ToolbarView!
|
||||||
|
|
||||||
|
private var gestureState: GestureState?
|
||||||
|
private var trackingScroll = false
|
||||||
|
private var scrollStartedBelowEnd = false
|
||||||
|
private var prevScrollViewContentOffset: CGPoint?
|
||||||
|
private var toolbarOffset: CGFloat = 0 {
|
||||||
|
didSet {
|
||||||
|
let realOffset = toolbarOffset * max(toolbarView.bounds.height, navBarView.bounds.height)
|
||||||
|
toolbarView.transform = CGAffineTransform(translationX: 0, y: realOffset)
|
||||||
|
navBarView.transform = CGAffineTransform(translationX: 0, y: -realOffset)
|
||||||
|
|
||||||
|
if (oldValue <= 0.5 && toolbarOffset > 0.5) || (oldValue > 0.5 && toolbarOffset <= 0.5) {
|
||||||
|
setNeedsStatusBarAppearanceUpdate()
|
||||||
|
|
||||||
|
if navBarView.textField.isFirstResponder {
|
||||||
|
navBarView.textField.resignFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override var prefersStatusBarHidden: Bool {
|
||||||
|
toolbarOffset > 0.5
|
||||||
|
}
|
||||||
|
|
||||||
|
override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
|
||||||
|
.slide
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cancellables = [AnyCancellable]()
|
||||||
|
|
||||||
|
init(navigator: NavigationManager) {
|
||||||
|
self.navigator = navigator
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
browserContainer = UIView()
|
||||||
|
browserContainer.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.embedSubview(browserContainer)
|
||||||
|
|
||||||
|
currentBrowserVC = createBrowserVC(url: navigator.currentURL)
|
||||||
|
currentBrowserVC.scrollViewDelegate = self
|
||||||
|
embedChild(currentBrowserVC, in: browserContainer)
|
||||||
|
|
||||||
|
navBarView = NavigationBarView(navigator: navigator)
|
||||||
|
navBarView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(navBarView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
navBarView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
navBarView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
navBarView.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
toolbarView = ToolbarView(navigator: navigator)
|
||||||
|
toolbarView.showTableOfContents = self.showTableOfContents
|
||||||
|
toolbarView.showShareSheet = self.showShareSheet
|
||||||
|
toolbarView.showPreferences = self.showPreferences
|
||||||
|
toolbarView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(toolbarView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
toolbarView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
|
toolbarView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||||
|
toolbarView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
|
])
|
||||||
|
|
||||||
|
navigator.navigationOperation
|
||||||
|
.sink(receiveValue: self.onNavigate)
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
view.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(panGestureRecognized)))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLayoutSubviews() {
|
||||||
|
super.viewDidLayoutSubviews()
|
||||||
|
|
||||||
|
setBrowserVCSafeAreaInsets(currentBrowserVC)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createBrowserVC(url: URL) -> BrowserWebViewController {
|
||||||
|
let vc = BrowserWebViewController(navigator: navigator, url: url)
|
||||||
|
setBrowserVCSafeAreaInsets(vc)
|
||||||
|
return vc
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setBrowserVCSafeAreaInsets(_ vc: BrowserWebViewController) {
|
||||||
|
guard let toolbarView = toolbarView,
|
||||||
|
let navBarView = navBarView else { return }
|
||||||
|
vc.additionalSafeAreaInsets = UIEdgeInsets(
|
||||||
|
top: navBarView.bounds.height - view.safeAreaInsets.top,
|
||||||
|
left: 0,
|
||||||
|
bottom: toolbarView.bounds.height - view.safeAreaInsets.bottom,
|
||||||
|
right: 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onNavigate(_ operation: NavigationManager.Operation) {
|
||||||
|
let newVC: BrowserWebViewController
|
||||||
|
|
||||||
|
switch operation {
|
||||||
|
case .go:
|
||||||
|
backBrowserVCs.append(currentBrowserVC)
|
||||||
|
newVC = BrowserWebViewController(navigator: navigator, url: navigator.currentURL)
|
||||||
|
|
||||||
|
case .reload:
|
||||||
|
currentBrowserVC.reload()
|
||||||
|
return
|
||||||
|
|
||||||
|
case let .backward(count: count):
|
||||||
|
var removed = backBrowserVCs.suffix(count)
|
||||||
|
backBrowserVCs.removeLast(count)
|
||||||
|
forwardBrowserVCs.insert(currentBrowserVC, at: 0)
|
||||||
|
newVC = removed.removeFirst()
|
||||||
|
forwardBrowserVCs.insert(contentsOf: removed, at: 0)
|
||||||
|
|
||||||
|
case let .forward(count: count):
|
||||||
|
var removed = forwardBrowserVCs.prefix(count)
|
||||||
|
forwardBrowserVCs.removeFirst(count)
|
||||||
|
backBrowserVCs.append(currentBrowserVC)
|
||||||
|
newVC = removed.removeFirst()
|
||||||
|
backBrowserVCs.append(contentsOf: removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBrowserVC.removeViewAndController()
|
||||||
|
currentBrowserVC.scrollViewDelegate = nil
|
||||||
|
currentBrowserVC = newVC
|
||||||
|
currentBrowserVC.scrollViewDelegate = self
|
||||||
|
embedChild(newVC, in: browserContainer)
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) {
|
||||||
|
self.toolbarOffset = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private let startEdgeNavigationSwipeDistance: CGFloat = 75
|
||||||
|
private let finishEdgeNavigationVelocityThreshold: CGFloat = 500
|
||||||
|
private let edgeNavigationMaxDimmingAlpha: CGFloat = 0.35
|
||||||
|
private let edgeNavigationParallaxFactor: CGFloat = 0.25
|
||||||
|
private let totalEdgeNavigationTime: TimeInterval = 0.4
|
||||||
|
|
||||||
|
@objc private func panGestureRecognized(_ recognizer: UIPanGestureRecognizer) {
|
||||||
|
let location = recognizer.location(in: view)
|
||||||
|
let velocity = recognizer.velocity(in: view)
|
||||||
|
|
||||||
|
switch recognizer.state {
|
||||||
|
case .began:
|
||||||
|
// swipe gestures cannot begin in navbar/toolbar bounds
|
||||||
|
let min = view.convert(navBarView.bounds, from: navBarView).maxY
|
||||||
|
let max = view.convert(toolbarView.bounds, from: toolbarView).minY
|
||||||
|
if toolbarOffset == 0 && (location.y < min || location.y > max) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if location.x < startEdgeNavigationSwipeDistance && velocity.x > 0 && navigator.backStack.count > 0 {
|
||||||
|
let older = backBrowserVCs.last ?? BrowserWebViewController(navigator: navigator, url: navigator.backStack.last!)
|
||||||
|
embedChild(older, in: browserContainer)
|
||||||
|
older.view.layer.zPosition = -2
|
||||||
|
older.view.transform = CGAffineTransform(translationX: -1 * edgeNavigationParallaxFactor * view.bounds.width, y: 0)
|
||||||
|
|
||||||
|
let dimmingView = UIView()
|
||||||
|
dimmingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
dimmingView.backgroundColor = .black
|
||||||
|
dimmingView.layer.zPosition = -1
|
||||||
|
dimmingView.alpha = edgeNavigationMaxDimmingAlpha
|
||||||
|
browserContainer.embedSubview(dimmingView)
|
||||||
|
let animator = UIViewPropertyAnimator(duration: totalEdgeNavigationTime, curve: .easeInOut) {
|
||||||
|
dimmingView.alpha = 0
|
||||||
|
older.view.transform = .identity
|
||||||
|
self.currentBrowserVC.view.transform = CGAffineTransform(translationX: self.view.bounds.width, y: 0)
|
||||||
|
}
|
||||||
|
animator.addCompletion { (position) in
|
||||||
|
dimmingView.removeFromSuperview()
|
||||||
|
|
||||||
|
older.view.transform = .identity
|
||||||
|
older.view.layer.zPosition = 0
|
||||||
|
older.removeViewAndController()
|
||||||
|
|
||||||
|
self.currentBrowserVC.view.transform = .identity
|
||||||
|
|
||||||
|
if position == .end {
|
||||||
|
self.navigator.goBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.navBarView.textField.isFirstResponder {
|
||||||
|
self.navBarView.textField.resignFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gestureState = .backwards(animator)
|
||||||
|
} else if location.x > view.bounds.width - startEdgeNavigationSwipeDistance && velocity.x < 0 && navigator.forwardStack.count > 0 {
|
||||||
|
let newer = forwardBrowserVCs.first ?? BrowserWebViewController(navigator: navigator, url: navigator.backStack.first!)
|
||||||
|
embedChild(newer, in: browserContainer)
|
||||||
|
newer.view.transform = CGAffineTransform(translationX: view.bounds.width, y: 0)
|
||||||
|
newer.view.layer.zPosition = 2
|
||||||
|
|
||||||
|
let dimmingView = UIView()
|
||||||
|
dimmingView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
dimmingView.backgroundColor = .black
|
||||||
|
dimmingView.layer.zPosition = 1
|
||||||
|
dimmingView.alpha = 0
|
||||||
|
browserContainer.embedSubview(dimmingView)
|
||||||
|
let animator = UIViewPropertyAnimator(duration: totalEdgeNavigationTime, curve: .easeInOut) {
|
||||||
|
dimmingView.alpha = self.edgeNavigationMaxDimmingAlpha
|
||||||
|
newer.view.transform = .identity
|
||||||
|
self.currentBrowserVC.view.transform = CGAffineTransform(translationX: -1 * self.edgeNavigationParallaxFactor * self.view.bounds.width, y: 0)
|
||||||
|
}
|
||||||
|
animator.addCompletion { (position) in
|
||||||
|
dimmingView.removeFromSuperview()
|
||||||
|
|
||||||
|
newer.removeViewAndController()
|
||||||
|
newer.view.layer.zPosition = 0
|
||||||
|
newer.view.transform = .identity
|
||||||
|
|
||||||
|
self.currentBrowserVC.view.transform = .identity
|
||||||
|
|
||||||
|
if position == .end {
|
||||||
|
self.navigator.goForward()
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.navBarView.textField.isFirstResponder {
|
||||||
|
self.navBarView.textField.resignFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gestureState = .forwards(animator)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .changed:
|
||||||
|
let translation = recognizer.translation(in: view)
|
||||||
|
|
||||||
|
switch gestureState {
|
||||||
|
case let .backwards(animator):
|
||||||
|
animator.fractionComplete = translation.x / view.bounds.width
|
||||||
|
case let .forwards(animator):
|
||||||
|
animator.fractionComplete = abs(translation.x) / view.bounds.width
|
||||||
|
case nil:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case .ended, .cancelled:
|
||||||
|
switch gestureState {
|
||||||
|
case let .backwards(animator):
|
||||||
|
let shouldComplete = location.x > view.bounds.width / 2 || velocity.x > finishEdgeNavigationVelocityThreshold
|
||||||
|
animator.isReversed = !shouldComplete
|
||||||
|
animator.startAnimation()
|
||||||
|
case let .forwards(animator):
|
||||||
|
let shouldComplete = location.x < view.bounds.width / 2 || velocity.x < -finishEdgeNavigationVelocityThreshold
|
||||||
|
animator.isReversed = !shouldComplete
|
||||||
|
animator.startAnimation()
|
||||||
|
case nil:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
gestureState = nil
|
||||||
|
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showTableOfContents() {
|
||||||
|
guard let doc = currentBrowserVC.document else { return }
|
||||||
|
let view = TableOfContentsView(document: doc) { (lineIndexToJumpTo) in
|
||||||
|
self.dismiss(animated: true) {
|
||||||
|
if let index = lineIndexToJumpTo {
|
||||||
|
self.currentBrowserVC.scrollToLine(index: index, animated: !UIAccessibility.isReduceMotionEnabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let host = UIHostingController(rootView: view)
|
||||||
|
present(host, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showShareSheet(_ source: UIView) {
|
||||||
|
let vc = UIActivityViewController(activityItems: [navigator.currentURL], applicationActivities: nil)
|
||||||
|
vc.popoverPresentationController?.sourceView = source
|
||||||
|
present(vc, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showPreferences() {
|
||||||
|
let host = UIHostingController(rootView: PreferencesView(dismiss: { self.dismiss(animated: true) }))
|
||||||
|
present(host, animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BrowserNavigationController {
|
||||||
|
enum GestureState {
|
||||||
|
case backwards(UIViewPropertyAnimator)
|
||||||
|
case forwards(UIViewPropertyAnimator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BrowserNavigationController: UIScrollViewDelegate {
|
||||||
|
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
|
||||||
|
trackingScroll = true
|
||||||
|
prevScrollViewContentOffset = scrollView.contentOffset
|
||||||
|
scrollStartedBelowEnd = scrollView.contentOffset.y >= (scrollView.contentSize.height - scrollView.bounds.height + scrollView.safeAreaInsets.bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||||
|
guard trackingScroll else { return }
|
||||||
|
defer { prevScrollViewContentOffset = scrollView.contentOffset }
|
||||||
|
guard let prevOffset = prevScrollViewContentOffset else { return }
|
||||||
|
|
||||||
|
let delta = scrollView.contentOffset.y - prevOffset.y
|
||||||
|
|
||||||
|
let belowEnd = scrollView.contentOffset.y > (scrollView.contentSize.height - scrollView.bounds.height + scrollView.safeAreaInsets.bottom)
|
||||||
|
|
||||||
|
if belowEnd {
|
||||||
|
if scrollStartedBelowEnd {
|
||||||
|
UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseInOut) {
|
||||||
|
self.toolbarOffset = 0
|
||||||
|
}
|
||||||
|
trackingScroll = false
|
||||||
|
}
|
||||||
|
} else if delta > 0 || (delta < 0 && toolbarOffset < 1) {
|
||||||
|
let normalizedDelta = delta / max(toolbarView.bounds.height, navBarView.bounds.height)
|
||||||
|
toolbarOffset = max(0, min(1, toolbarOffset + normalizedDelta))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer<CGPoint>) {
|
||||||
|
guard trackingScroll else { return }
|
||||||
|
trackingScroll = false
|
||||||
|
|
||||||
|
if velocity.y == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalOffset: CGFloat = velocity.y < 0 ? 0 : 1
|
||||||
|
UIView.animate(withDuration: 0.15, delay: 0, options: .curveEaseOut) {
|
||||||
|
self.toolbarOffset = finalOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,167 +0,0 @@
|
||||||
//
|
|
||||||
// BrowserViewController.swift
|
|
||||||
// Gemini-iOS
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/28/20.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
import SwiftUI
|
|
||||||
import BrowserCore
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
class BrowserViewController: UIViewController, UIScrollViewDelegate {
|
|
||||||
|
|
||||||
let navigator: NavigationManager
|
|
||||||
|
|
||||||
private var scrollView: UIScrollView!
|
|
||||||
|
|
||||||
private var browserHost: UIHostingController<BrowserView>!
|
|
||||||
private var navBarHost: UIHostingController<NavigationBar>!
|
|
||||||
private var toolBarHost: UIHostingController<ToolBar>!
|
|
||||||
|
|
||||||
private var prevScrollViewContentOffset: CGPoint?
|
|
||||||
|
|
||||||
private var barAnimator: UIViewPropertyAnimator?
|
|
||||||
|
|
||||||
private var cancellables = [AnyCancellable]()
|
|
||||||
|
|
||||||
init(navigator: NavigationManager) {
|
|
||||||
self.navigator = navigator
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
scrollView = UIScrollView()
|
|
||||||
scrollView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
scrollView.keyboardDismissMode = .interactive
|
|
||||||
view.addSubview(scrollView)
|
|
||||||
scrollView.delegate = self
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
scrollView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
])
|
|
||||||
|
|
||||||
browserHost = UIHostingController(rootView: BrowserView(navigator: navigator, scrollingEnabled: false))
|
|
||||||
browserHost.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
scrollView.addSubview(browserHost.view)
|
|
||||||
addChild(browserHost)
|
|
||||||
browserHost.didMove(toParent: self)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
scrollView.contentLayoutGuide.leadingAnchor.constraint(equalTo: browserHost.view.leadingAnchor),
|
|
||||||
scrollView.contentLayoutGuide.trailingAnchor.constraint(equalTo: browserHost.view.trailingAnchor),
|
|
||||||
scrollView.contentLayoutGuide.topAnchor.constraint(equalTo: browserHost.view.topAnchor),
|
|
||||||
scrollView.contentLayoutGuide.bottomAnchor.constraint(equalTo: browserHost.view.bottomAnchor),
|
|
||||||
browserHost.view.widthAnchor.constraint(equalTo: view.widthAnchor),
|
|
||||||
|
|
||||||
// make sure the browser host view is at least the screen height so the loading indicator appears centered
|
|
||||||
browserHost.view.heightAnchor.constraint(greaterThanOrEqualTo: view.heightAnchor),
|
|
||||||
])
|
|
||||||
|
|
||||||
navBarHost = UIHostingController(rootView: NavigationBar(navigator: navigator))
|
|
||||||
navBarHost.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(navBarHost.view)
|
|
||||||
addChild(navBarHost)
|
|
||||||
navBarHost.didMove(toParent: self)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
navBarHost.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
navBarHost.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
navBarHost.view.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
])
|
|
||||||
|
|
||||||
toolBarHost = UIHostingController(rootView: ToolBar(navigator: navigator, shareCurrentURL: {
|
|
||||||
let vc = UIActivityViewController(activityItems: [self.navigator.currentURL], applicationActivities: nil)
|
|
||||||
self.present(vc, animated: true)
|
|
||||||
}))
|
|
||||||
toolBarHost.view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(toolBarHost.view)
|
|
||||||
addChild(toolBarHost)
|
|
||||||
toolBarHost.didMove(toParent: self)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
toolBarHost.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
toolBarHost.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
toolBarHost.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
])
|
|
||||||
|
|
||||||
navigator.$currentURL
|
|
||||||
.sink { (_) in
|
|
||||||
self.scrollView.contentOffset = .zero
|
|
||||||
self.navBarHost.view.transform = .identity
|
|
||||||
self.toolBarHost.view.transform = .identity
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidLayoutSubviews() {
|
|
||||||
super.viewDidLayoutSubviews()
|
|
||||||
|
|
||||||
let insets = UIEdgeInsets(
|
|
||||||
top: navBarHost.view.bounds.height - view.safeAreaInsets.top,
|
|
||||||
left: 0,
|
|
||||||
bottom: toolBarHost.view.bounds.height - view.safeAreaInsets.bottom,
|
|
||||||
right: 0
|
|
||||||
)
|
|
||||||
scrollView.contentInset = insets
|
|
||||||
scrollView.scrollIndicatorInsets = insets
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - UIScrollViewDelegate
|
|
||||||
|
|
||||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
|
||||||
var scrollViewDelta: CGFloat = 0
|
|
||||||
if let prev = prevScrollViewContentOffset {
|
|
||||||
scrollViewDelta = scrollView.contentOffset.y - prev.y
|
|
||||||
}
|
|
||||||
prevScrollViewContentOffset = scrollView.contentOffset
|
|
||||||
|
|
||||||
// When certain state changes happen, the scroll view seems to "scroll" by top the safe area inset.
|
|
||||||
// It's not actually user scrolling, and this screws up our animation, so we ignore it.
|
|
||||||
guard abs(scrollViewDelta) != view.safeAreaInsets.top,
|
|
||||||
scrollViewDelta != 0,
|
|
||||||
scrollView.contentOffset.y > 0 else { return }
|
|
||||||
|
|
||||||
let barAnimator: UIViewPropertyAnimator
|
|
||||||
if let animator = self.barAnimator {
|
|
||||||
barAnimator = animator
|
|
||||||
} else {
|
|
||||||
navBarHost.view.transform = .identity
|
|
||||||
toolBarHost.view.transform = .identity
|
|
||||||
barAnimator = UIViewPropertyAnimator(duration: 0.2, curve: .linear) {
|
|
||||||
self.navBarHost.view.transform = CGAffineTransform(translationX: 0, y: -self.navBarHost.view.frame.height)
|
|
||||||
self.toolBarHost.view.transform = CGAffineTransform(translationX: 0, y: self.toolBarHost.view.frame.height)
|
|
||||||
}
|
|
||||||
if scrollViewDelta < 0 {
|
|
||||||
barAnimator.fractionComplete = 1
|
|
||||||
}
|
|
||||||
barAnimator.addCompletion { (_) in
|
|
||||||
self.barAnimator = nil
|
|
||||||
}
|
|
||||||
self.barAnimator = barAnimator
|
|
||||||
}
|
|
||||||
|
|
||||||
let progressDelta = scrollViewDelta / navBarHost.view.bounds.height
|
|
||||||
barAnimator.fractionComplete = max(0, min(1, barAnimator.fractionComplete + progressDelta))
|
|
||||||
}
|
|
||||||
|
|
||||||
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
|
|
||||||
if let barAnimator = barAnimator {
|
|
||||||
if barAnimator.fractionComplete < 0.5 {
|
|
||||||
barAnimator.isReversed = true
|
|
||||||
}
|
|
||||||
barAnimator.startAnimation()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -0,0 +1,321 @@
|
||||||
|
//
|
||||||
|
// BrowserWebViewController.swift
|
||||||
|
// Gemini-iOS
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/16/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import BrowserCore
|
||||||
|
import WebKit
|
||||||
|
import GeminiProtocol
|
||||||
|
import GeminiFormat
|
||||||
|
import GeminiRenderer
|
||||||
|
import SafariServices
|
||||||
|
|
||||||
|
class BrowserWebViewController: UIViewController {
|
||||||
|
|
||||||
|
let navigator: NavigationManager
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
weak var scrollViewDelegate: UIScrollViewDelegate? {
|
||||||
|
didSet {
|
||||||
|
if isViewLoaded {
|
||||||
|
webView.scrollView.delegate = scrollViewDelegate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var task: GeminiDataTask?
|
||||||
|
private let renderer = GeminiHTMLRenderer()
|
||||||
|
private(set) var document: Document?
|
||||||
|
private var loaded = false
|
||||||
|
private var loadedFallback = 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()
|
||||||
|
|
||||||
|
let documentURL = self.url
|
||||||
|
renderer.linkPrefix = { (url: URL) -> String in
|
||||||
|
let symbolClass: String
|
||||||
|
if url.scheme == "gemini" {
|
||||||
|
if url.host == documentURL.host {
|
||||||
|
symbolClass = "arrow-right"
|
||||||
|
} else {
|
||||||
|
symbolClass = "link"
|
||||||
|
}
|
||||||
|
} else if url.scheme == "http" || url.scheme == "https" {
|
||||||
|
symbolClass = "safari"
|
||||||
|
} else if url.scheme == "mailto" {
|
||||||
|
symbolClass = "envelope"
|
||||||
|
} else {
|
||||||
|
symbolClass = "arrow-up-left-square"
|
||||||
|
}
|
||||||
|
return "<span class=\"symbol \(symbolClass)\" aria-hidden=\"true\"></span>"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
view.backgroundColor = .systemBackground
|
||||||
|
|
||||||
|
webView = WKWebView()
|
||||||
|
webView.backgroundColor = .systemBackground
|
||||||
|
webView.isOpaque = false
|
||||||
|
webView.navigationDelegate = self
|
||||||
|
webView.uiDelegate = self
|
||||||
|
// it is safe to set the delegate of the web view's internal scroll view becuase WebKit takes care of forwarding between its internal delegate and our own
|
||||||
|
webView.scrollView.delegate = scrollViewDelegate
|
||||||
|
// this doesn't default to .default :S
|
||||||
|
webView.scrollView.indicatorStyle = .default
|
||||||
|
webView.scrollView.keyboardDismissMode = .interactive
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reload() {
|
||||||
|
loaded = false
|
||||||
|
loadedFallback = false
|
||||||
|
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 {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
print("Trying to redirect to: '\(response.meta)'")
|
||||||
|
if let redirect = URL(string: response.meta) {
|
||||||
|
self.navigator.changeURL(redirect)
|
||||||
|
} else {
|
||||||
|
self.showError(message: "Invalid redirect URL: '\(response.meta)'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if response.status.isSuccess {
|
||||||
|
if response.mimeType == "text/gemini",
|
||||||
|
let text = response.bodyText {
|
||||||
|
self.renderDocument(GeminiParser.parse(text: text, baseURL: url))
|
||||||
|
} else {
|
||||||
|
self.renderFallback(response: response)
|
||||||
|
}
|
||||||
|
} 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) {
|
||||||
|
self.document = doc
|
||||||
|
|
||||||
|
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 func renderFallback(response: GeminiResponse) {
|
||||||
|
guard let body = response.body,
|
||||||
|
let mimeType = response.mimeType else {
|
||||||
|
self.showError(message: "Unknown error: \(response.header)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.webView.isHidden = false
|
||||||
|
self.errorStack.isHidden = true
|
||||||
|
self.activityIndicator.isHidden = true
|
||||||
|
self.activityIndicator.stopAnimating()
|
||||||
|
|
||||||
|
self.loadedFallback = true
|
||||||
|
|
||||||
|
// todo: probably shouldn't assume this is UTF-8
|
||||||
|
self.webView.load(body, mimeType: mimeType, characterEncodingName: "utf-8", baseURL: self.url)
|
||||||
|
// When showing an image, the safe area insets seem to be ignored. This isn't perfect
|
||||||
|
// (there's a little extra space between the bottom of the nav bar and the top of the image),
|
||||||
|
// but it's better than the image being obscured.
|
||||||
|
self.webView.scrollView.contentInset = self.webView.safeAreaInsets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrollToLine(index: Int, animated: Bool) {
|
||||||
|
if animated {
|
||||||
|
webView.evaluateJavaScript("document.getElementById('l\(index)').getBoundingClientRect().top + window.scrollY") { (result, error) in
|
||||||
|
guard let result = result as? CGFloat else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let scrollView = self.webView.scrollView
|
||||||
|
let y = result * scrollView.zoomScale - scrollView.safeAreaInsets.top
|
||||||
|
let maxY = scrollView.contentSize.height - scrollView.bounds.height + scrollView.safeAreaInsets.bottom
|
||||||
|
let finalOffsetY = min(y, maxY)
|
||||||
|
self.webView.scrollView.setContentOffset(CGPoint(x: 0, y: finalOffsetY), animated: true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
webView.evaluateJavaScript("document.getElementById('l\(index)').scrollIntoView();")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let preamble = """
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<link rel="stylesheet" href="\(Bundle.main.url(forResource: "browser", withExtension: "css")!.absoluteString)">
|
||||||
|
<style>
|
||||||
|
\(symbolStyles)
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
"""
|
||||||
|
|
||||||
|
private static let symbolStyles = SymbolCache.symbols.map { (k, v) in
|
||||||
|
".symbol.\(k.replacingOccurrences(of: ".", with: "-")) { background-image: url(\"data:image/png;base64,\(v)\"); }"
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
|
||||||
|
private static let postamble = """
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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 if loadedFallback {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
} else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
navigator.changeURL(url)
|
||||||
|
loadDocument()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BrowserWebViewController: WKUIDelegate {
|
||||||
|
func webView(_ webView: WKWebView, contextMenuConfigurationForElement elementInfo: WKContextMenuElementInfo, completionHandler: @escaping (UIContextMenuConfiguration?) -> Void) {
|
||||||
|
guard let url = elementInfo.linkURL,
|
||||||
|
url.scheme == "gemini" || url.scheme == "http" || url.scheme == "https" else {
|
||||||
|
completionHandler(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let config = UIContextMenuConfiguration(identifier: nil) {
|
||||||
|
if url.scheme == "gemini" {
|
||||||
|
return BrowserWebViewController(navigator: self.navigator, url: url)
|
||||||
|
} else {
|
||||||
|
return SFSafariViewController(url: url)
|
||||||
|
}
|
||||||
|
} actionProvider: { (_) in
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
completionHandler(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
|
||||||
|
animator.preferredCommitStyle = .pop
|
||||||
|
let url = elementInfo.linkURL!
|
||||||
|
animator.addCompletion {
|
||||||
|
if url.scheme == "http" || url.scheme == "https" {
|
||||||
|
self.present(animator.previewViewController!, animated: true)
|
||||||
|
} else {
|
||||||
|
self.navigator.changeURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,138 +0,0 @@
|
||||||
//
|
|
||||||
// ContentView.swift
|
|
||||||
// Gemini-iOS
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 7/15/20.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import BrowserCore
|
|
||||||
|
|
||||||
// This is not currently used as SwiftUI's ScrollView has no mechanism for detecting when it stops deceleraing,
|
|
||||||
// which is necessary to preven tthe bars from being left in a partially visible state.
|
|
||||||
struct ContentView: View {
|
|
||||||
@ObservedObject private var navigator: NavigationManager
|
|
||||||
@State private var urlFieldContents: String
|
|
||||||
@State private var showPreferencesSheet = false
|
|
||||||
private let shareCurrentURL: () -> Void
|
|
||||||
@State private var prevScrollOffset: CGFloat = 0
|
|
||||||
@State private var scrollOffset: CGFloat = 0 {
|
|
||||||
didSet {
|
|
||||||
prevScrollOffset = oldValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@State private var barOffset: CGFloat = 0
|
|
||||||
@State private var navBarHeight: CGFloat = 0
|
|
||||||
@State private var toolBarHeight: CGFloat = 0
|
|
||||||
|
|
||||||
init(navigator: NavigationManager, shareCurrentURL: @escaping () -> Void) {
|
|
||||||
self.navigator = navigator
|
|
||||||
self._urlFieldContents = State(initialValue: navigator.currentURL.absoluteString)
|
|
||||||
self.shareCurrentURL = shareCurrentURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
ZStack {
|
|
||||||
GeometryReader { (outer: GeometryProxy) in
|
|
||||||
ScrollView(.vertical) {
|
|
||||||
Color.clear.frame(height: navBarHeight)
|
|
||||||
|
|
||||||
BrowserView(navigator: navigator, scrollingEnabled: false)
|
|
||||||
.background(GeometryReader { (inner: GeometryProxy) in
|
|
||||||
Color.clear.preference(key: ScrollOffsetPrefKey.self, value: -inner.frame(in: .global).minY + outer.frame(in: .global).minY)
|
|
||||||
})
|
|
||||||
|
|
||||||
Color.clear.frame(height: toolBarHeight)
|
|
||||||
}
|
|
||||||
.onPreferenceChange(ScrollOffsetPrefKey.self) {
|
|
||||||
scrollOffset = $0
|
|
||||||
let delta = scrollOffset - prevScrollOffset
|
|
||||||
|
|
||||||
// When certain state changes happen, the scroll view seems to "scroll" by the top safe area inset.
|
|
||||||
// It's not actually user scrolling, and this screws up our animation, so we ignore it.
|
|
||||||
guard abs(delta) != outer.safeAreaInsets.top else { return }
|
|
||||||
|
|
||||||
if delta != 0 {
|
|
||||||
barOffset += delta
|
|
||||||
}
|
|
||||||
|
|
||||||
barOffset = max(0, min(navBarHeight + outer.safeAreaInsets.top, barOffset))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
NavigationBar(navigator: navigator)
|
|
||||||
.background(GeometryReader { (geom: GeometryProxy) in
|
|
||||||
Color.clear.preference(key: NavBarHeightPrefKey.self, value: geom.frame(in: .global).height)
|
|
||||||
})
|
|
||||||
.offset(y: -barOffset)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
ToolBar(navigator: navigator, shareCurrentURL: shareCurrentURL)
|
|
||||||
.background(GeometryReader { (geom: GeometryProxy) in
|
|
||||||
Color.clear.preference(key: ToolBarHeightPrefKey.self, value: geom.frame(in: .global).height)
|
|
||||||
})
|
|
||||||
.offset(y: barOffset)
|
|
||||||
}
|
|
||||||
.onPreferenceChange(NavBarHeightPrefKey.self) {
|
|
||||||
navBarHeight = $0
|
|
||||||
print("nav bar height: \($0)")
|
|
||||||
}
|
|
||||||
.onPreferenceChange(ToolBarHeightPrefKey.self) {
|
|
||||||
toolBarHeight = $0
|
|
||||||
print("tool bar height: \($0)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear(perform: tweakAppearance)
|
|
||||||
.onReceive(navigator.$currentURL, perform: { (new) in
|
|
||||||
urlFieldContents = new.absoluteString
|
|
||||||
})
|
|
||||||
.sheet(isPresented: $showPreferencesSheet, content: {
|
|
||||||
PreferencesView(presented: $showPreferencesSheet)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private func tweakAppearance() {
|
|
||||||
UIScrollView.appearance().keyboardDismissMode = .interactive
|
|
||||||
}
|
|
||||||
|
|
||||||
private func commitURL() {
|
|
||||||
guard let url = URL(string: urlFieldContents) else { return }
|
|
||||||
navigator.changeURL(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct ScrollOffsetPrefKey: PreferenceKey {
|
|
||||||
typealias Value = CGFloat
|
|
||||||
static var defaultValue: CGFloat = 0
|
|
||||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
||||||
value += nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct NavBarHeightPrefKey: PreferenceKey {
|
|
||||||
typealias Value = CGFloat
|
|
||||||
static var defaultValue: CGFloat = 0
|
|
||||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
||||||
value += nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct ToolBarHeightPrefKey: PreferenceKey {
|
|
||||||
typealias Value = CGFloat
|
|
||||||
static var defaultValue: CGFloat = 0
|
|
||||||
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
||||||
value += nextValue()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate enum ScrollDirection {
|
|
||||||
case up, down, none
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ContentView_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
ContentView(navigator: NavigationManager(url: URL(string: "gemini://localhost/overview.gmi")!), shareCurrentURL: {})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,51 +0,0 @@
|
||||||
//
|
|
||||||
// NavigationBar.swift
|
|
||||||
// Gemini-iOS
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/28/20.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import BrowserCore
|
|
||||||
|
|
||||||
struct NavigationBar: View {
|
|
||||||
@ObservedObject var navigator: NavigationManager
|
|
||||||
@State private var urlFieldContents: String
|
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
|
||||||
|
|
||||||
init(navigator: NavigationManager) {
|
|
||||||
self.navigator = navigator
|
|
||||||
self._urlFieldContents = State(initialValue: navigator.currentURL.absoluteString)
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 0) {
|
|
||||||
TextField("URL", text: $urlFieldContents, onCommit: commitURL)
|
|
||||||
.keyboardType(.URL)
|
|
||||||
.autocapitalization(.none)
|
|
||||||
.disableAutocorrection(true)
|
|
||||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
|
||||||
.padding([.leading, .trailing, .bottom])
|
|
||||||
|
|
||||||
Rectangle()
|
|
||||||
.frame(height: 1)
|
|
||||||
.foregroundColor(Color(white: colorScheme == .dark ? 0.25 : 0.75))
|
|
||||||
}
|
|
||||||
.background(Color(UIColor.systemBackground).edgesIgnoringSafeArea(.top))
|
|
||||||
.onReceive(navigator.$currentURL) { (newURL) in
|
|
||||||
urlFieldContents = newURL.absoluteString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func commitURL() {
|
|
||||||
guard let url = URL(string: urlFieldContents) else { return }
|
|
||||||
navigator.changeURL(url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct NavigationBar_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
NavigationBar(navigator: NavigationManager(url: URL(string: "gemini://localhost/overview.gmi")!))
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
//
|
||||||
|
// NavigationBarView.swift
|
||||||
|
// Gemini-iOS
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/19/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import BrowserCore
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class NavigationBarView: UIView {
|
||||||
|
|
||||||
|
let navigator: NavigationManager
|
||||||
|
|
||||||
|
private var border: UIView!
|
||||||
|
private(set) var textField: UITextField!
|
||||||
|
|
||||||
|
private var cancellables = [AnyCancellable]()
|
||||||
|
|
||||||
|
init(navigator: NavigationManager) {
|
||||||
|
self.navigator = navigator
|
||||||
|
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
backgroundColor = .systemBackground
|
||||||
|
|
||||||
|
border = UIView()
|
||||||
|
border.backgroundColor = UIColor(white: traitCollection.userInterfaceStyle == .dark ? 0.25 : 0.75, alpha: 1)
|
||||||
|
border.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(border)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
border.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
border.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
border.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||||
|
border.heightAnchor.constraint(equalToConstant: 1),
|
||||||
|
])
|
||||||
|
|
||||||
|
textField = UITextField()
|
||||||
|
textField.text = navigator.displayURL
|
||||||
|
textField.borderStyle = .roundedRect
|
||||||
|
textField.keyboardType = .URL
|
||||||
|
textField.returnKeyType = .go
|
||||||
|
textField.autocapitalizationType = .none
|
||||||
|
textField.autocorrectionType = .no
|
||||||
|
textField.addTarget(self, action: #selector(commitURL), for: .primaryActionTriggered)
|
||||||
|
textField.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(textField)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
|
||||||
|
textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
|
||||||
|
textField.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
|
||||||
|
textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -9),
|
||||||
|
])
|
||||||
|
|
||||||
|
navigator.$currentURL
|
||||||
|
.sink { (newURL) in
|
||||||
|
// can't use navigator.displayURL because the publisher fires before the underlying value is updated, so the displayURL getter returns the old value
|
||||||
|
var components = URLComponents(url: newURL, resolvingAgainstBaseURL: false)!
|
||||||
|
if components.port == 1965 {
|
||||||
|
components.port = nil
|
||||||
|
}
|
||||||
|
self.textField.text = components.string!
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
|
||||||
|
border.backgroundColor = UIColor(white: traitCollection.userInterfaceStyle == .dark ? 0.25 : 0.75, alpha: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func commitURL() {
|
||||||
|
textField.resignFirstResponder()
|
||||||
|
if let text = textField.text, var components = URLComponents(string: text) {
|
||||||
|
if components.scheme == nil {
|
||||||
|
components.scheme = "gemini"
|
||||||
|
}
|
||||||
|
navigator.changeURL(components.url!)
|
||||||
|
} else {
|
||||||
|
textField.text = navigator.displayURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -5,7 +5,7 @@
|
||||||
// Created by Shadowfacts on 9/27/20.
|
// Created by Shadowfacts on 9/27/20.
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import UIKit
|
||||||
|
|
||||||
class Preferences: Codable, ObservableObject {
|
class Preferences: Codable, ObservableObject {
|
||||||
|
|
||||||
|
@ -34,6 +34,8 @@ class Preferences: Codable, ObservableObject {
|
||||||
required init(from decoder: Decoder) throws {
|
required init(from decoder: Decoder) throws {
|
||||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
|
||||||
|
|
||||||
useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||||
useReaderMode = try container.decode(Bool.self, forKey: .useReaderMode)
|
useReaderMode = try container.decode(Bool.self, forKey: .useReaderMode)
|
||||||
}
|
}
|
||||||
|
@ -41,16 +43,25 @@ class Preferences: Codable, ObservableObject {
|
||||||
func encode(to encoder: Encoder) throws {
|
func encode(to encoder: Encoder) throws {
|
||||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
|
||||||
|
try container.encode(theme, forKey: .theme)
|
||||||
|
|
||||||
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
||||||
try container.encode(useReaderMode, forKey: .useReaderMode)
|
try container.encode(useReaderMode, forKey: .useReaderMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Published var theme = UIUserInterfaceStyle.unspecified
|
||||||
|
|
||||||
@Published var useInAppSafari = false
|
@Published var useInAppSafari = false
|
||||||
@Published var useReaderMode = false
|
@Published var useReaderMode = false
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case theme
|
||||||
|
|
||||||
case useInAppSafari
|
case useInAppSafari
|
||||||
case useReaderMode
|
case useReaderMode
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
extension UIUserInterfaceStyle: Codable {}
|
||||||
|
|
|
@ -8,27 +8,44 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PreferencesView: View {
|
struct PreferencesView: View {
|
||||||
@ObservedObject var preferences: Preferences = .shared
|
let dismiss: () -> Void
|
||||||
|
|
||||||
@Binding var presented: Bool
|
@ObservedObject var preferences: Preferences = .shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationView {
|
||||||
List {
|
List {
|
||||||
|
appearanceSection
|
||||||
|
|
||||||
safariSection
|
safariSection
|
||||||
}
|
}
|
||||||
.navigationBarTitle("Preferences")
|
.navigationBarTitle("Preferences")
|
||||||
.insetOrGroupedListStyle()
|
.insetOrGroupedListStyle()
|
||||||
.navigationBarItems(trailing: doneButton)
|
.navigationBarItems(trailing: doneButton)
|
||||||
}
|
}
|
||||||
|
.navigationViewStyle(StackNavigationViewStyle())
|
||||||
|
.onDisappear {
|
||||||
|
Preferences.save()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var doneButton: some View {
|
private var doneButton: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
presented = false
|
dismiss()
|
||||||
}, label: {
|
}, label: {
|
||||||
Text("Done")
|
Text("Done")
|
||||||
})
|
})
|
||||||
|
.hoverEffect(.highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var appearanceSection: some View {
|
||||||
|
Section(header: Text("Appearance")) {
|
||||||
|
Picker(selection: $preferences.theme, label: Text("Theme")) {
|
||||||
|
Text("Use System Theme").tag(UIUserInterfaceStyle.unspecified)
|
||||||
|
Text("Always Light").tag(UIUserInterfaceStyle.light)
|
||||||
|
Text("Always Dark").tag(UIUserInterfaceStyle.dark)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var safariSection: some View {
|
private var safariSection: some View {
|
||||||
|
@ -56,6 +73,6 @@ struct PreferencesView_Previews: PreviewProvider {
|
||||||
@State static var presented = true
|
@State static var presented = true
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
PreferencesView(presented: $presented)
|
PreferencesView(dismiss: {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgb(0, 122, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
padding-left: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol {
|
||||||
|
display: inline-block;
|
||||||
|
float: left;
|
||||||
|
width: 1.25em;
|
||||||
|
height: 1.25em;
|
||||||
|
margin-right: 0.25em;
|
||||||
|
background-size: contain;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
a {
|
||||||
|
color: rgb(10, 132, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
.symbol {
|
||||||
|
filter: invert(100%);
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import UIKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import BrowserCore
|
import BrowserCore
|
||||||
import SafariServices
|
import SafariServices
|
||||||
|
import Combine
|
||||||
|
|
||||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
|
@ -16,6 +17,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
|
|
||||||
var navigationManager: NavigationManager!
|
var navigationManager: NavigationManager!
|
||||||
|
|
||||||
|
private var cancellables = [AnyCancellable]()
|
||||||
|
|
||||||
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
|
||||||
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
|
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
|
||||||
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
|
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
|
||||||
|
@ -37,16 +40,24 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
navigationManager.delegate = self
|
navigationManager.delegate = self
|
||||||
|
|
||||||
// Create the SwiftUI view that provides the window contents.
|
// Create the SwiftUI view that provides the window contents.
|
||||||
// let contentView = ContentView(navigator: navigationManager, shareCurrentURL: self.shareCurrentURL)
|
// let contentView = ContentView(navigator: navigationManager)
|
||||||
|
|
||||||
// Use a UIHostingController as window root view controller.
|
// Use a UIHostingController as window root view controller.
|
||||||
if let windowScene = scene as? UIWindowScene {
|
if let windowScene = scene as? UIWindowScene {
|
||||||
let window = UIWindow(windowScene: windowScene)
|
let window = UIWindow(windowScene: windowScene)
|
||||||
|
window.overrideUserInterfaceStyle = Preferences.shared.theme
|
||||||
|
window.rootViewController = BrowserNavigationController(navigator: navigationManager)
|
||||||
// window.rootViewController = UIHostingController(rootView: contentView)
|
// window.rootViewController = UIHostingController(rootView: contentView)
|
||||||
window.rootViewController = BrowserViewController(navigator: navigationManager)
|
// window.rootViewController = BrowserViewController(navigator: navigationManager)
|
||||||
self.window = window
|
self.window = window
|
||||||
window.makeKeyAndVisible()
|
window.makeKeyAndVisible()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Preferences.shared.$theme
|
||||||
|
.sink { (newStyle) in
|
||||||
|
self.window!.overrideUserInterfaceStyle = newStyle
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
|
||||||
|
@ -83,17 +94,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||||
// to restore the scene back to its current state.
|
// to restore the scene back to its current state.
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shareCurrentURL() {
|
|
||||||
let vc = UIActivityViewController(activityItems: [navigationManager.currentURL], applicationActivities: nil)
|
|
||||||
window?.rootViewController?.present(vc, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SceneDelegate: NavigationManagerDelegate {
|
extension SceneDelegate: NavigationManagerDelegate {
|
||||||
func loadNonGeminiURL(_ url: URL) {
|
func loadNonGeminiURL(_ url: URL) {
|
||||||
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { (success) in
|
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { (success) in
|
||||||
if !success {
|
guard !success else { return }
|
||||||
|
if url.scheme == "http" || url.scheme == "https" {
|
||||||
if Preferences.shared.useInAppSafari {
|
if Preferences.shared.useInAppSafari {
|
||||||
let config = SFSafariViewController.Configuration()
|
let config = SFSafariViewController.Configuration()
|
||||||
config.entersReaderIfAvailable = Preferences.shared.useReaderMode
|
config.entersReaderIfAvailable = Preferences.shared.useReaderMode
|
||||||
|
@ -102,6 +109,13 @@ extension SceneDelegate: NavigationManagerDelegate {
|
||||||
} else {
|
} else {
|
||||||
UIApplication.shared.open(url, options: [:])
|
UIApplication.shared.open(url, options: [:])
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
let alert = UIAlertController(title: "Cannot open '\(url.scheme!)' URL", message: url.absoluteString, preferredStyle: .alert)
|
||||||
|
alert.addAction(UIAlertAction(title: "Copy URL", style: .default, handler: { (_) in
|
||||||
|
UIPasteboard.general.setObjects([url])
|
||||||
|
}))
|
||||||
|
alert.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil))
|
||||||
|
self.window!.rootViewController!.present(alert, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
//
|
||||||
|
// SymbolCache.swift
|
||||||
|
// Gemini-iOS
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/17/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct SymbolCache {
|
||||||
|
|
||||||
|
private(set) static var symbols = [String: String]()
|
||||||
|
|
||||||
|
private static let defaultSymbols = [
|
||||||
|
"arrow.right",
|
||||||
|
"link",
|
||||||
|
"safari",
|
||||||
|
"envelope",
|
||||||
|
"arrow.up.left.square",
|
||||||
|
]
|
||||||
|
|
||||||
|
static func load() {
|
||||||
|
defaultSymbols.forEach { loadSymbol(name: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadSymbol(name: String) {
|
||||||
|
let config = UIImage.SymbolConfiguration(pointSize: 16)
|
||||||
|
let symbol = UIImage(systemName: name, withConfiguration: config)!
|
||||||
|
let data = symbol.pngData()!
|
||||||
|
symbols[name] = data.base64EncodedString()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,65 @@
|
||||||
|
//
|
||||||
|
// TableOfContentsView.swift
|
||||||
|
// Gemini-iOS
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/20/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import GeminiFormat
|
||||||
|
|
||||||
|
struct TableOfContentsView: View {
|
||||||
|
private let entries: [Entry]
|
||||||
|
private let close: (Int?) -> Void
|
||||||
|
|
||||||
|
init(document: Document, close: @escaping (Int?) -> Void) {
|
||||||
|
let toc = TableOfContents(document: document)
|
||||||
|
self.entries = toc.entries.flatMap { TableOfContentsView.flattenToCEntry($0) }
|
||||||
|
self.close = close
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func flattenToCEntry(_ e: TableOfContents.Entry, depth: Int = 0) -> [Entry] {
|
||||||
|
guard case let .heading(text, level: _) = e.line else { fatalError() }
|
||||||
|
var entries = e.children.flatMap {
|
||||||
|
flattenToCEntry($0, depth: depth + 1)
|
||||||
|
}
|
||||||
|
entries.insert(Entry(title: text, lineIndex: e.lineIndex, depth: depth), at: 0)
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
List(Array(entries.enumerated()), id: \.0) { (a) in
|
||||||
|
Button {
|
||||||
|
close(a.1.lineIndex)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
.frame(width: CGFloat(a.1.depth * 25))
|
||||||
|
Text(verbatim: a.1.title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(PlainListStyle())
|
||||||
|
.navigationBarTitle("Table of Contents", displayMode: .inline)
|
||||||
|
.navigationBarItems(leading: Button("Cancel", action: {
|
||||||
|
close(nil)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
.navigationViewStyle(StackNavigationViewStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TableOfContentsView {
|
||||||
|
struct Entry {
|
||||||
|
let title: String
|
||||||
|
let lineIndex: Int
|
||||||
|
let depth: Int
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TableOfContentsView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
TableOfContentsView(document: Document(url: URL(string: "gemini://example.com")!, lines: [])) { (_) in }
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,83 +0,0 @@
|
||||||
//
|
|
||||||
// ToolBar.swift
|
|
||||||
// Gemini-iOS
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 9/28/20.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
import BrowserCore
|
|
||||||
|
|
||||||
struct ToolBar: View {
|
|
||||||
@ObservedObject var navigator: NavigationManager
|
|
||||||
let shareCurrentURL: () -> Void
|
|
||||||
@State private var showPreferencesSheet = false
|
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Rectangle()
|
|
||||||
.frame(height: 1)
|
|
||||||
.foregroundColor(Color(white: colorScheme == .dark ? 0.25 : 0.75))
|
|
||||||
|
|
||||||
HStack {
|
|
||||||
// use a group because this exceeds the 10 view limit :/
|
|
||||||
Group {
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: navigator.back) {
|
|
||||||
Image(systemName: "arrow.left")
|
|
||||||
.font(.system(size: 24))
|
|
||||||
}
|
|
||||||
.disabled(navigator.backStack.isEmpty)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: navigator.forward) {
|
|
||||||
Image(systemName: "arrow.right")
|
|
||||||
.font(.system(size: 24))
|
|
||||||
}
|
|
||||||
.disabled(navigator.forwardStack.isEmpty)
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: navigator.reload) {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
.font(.system(size: 24))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: shareCurrentURL) {
|
|
||||||
Image(systemName: "square.and.arrow.up")
|
|
||||||
.font(.system(size: 24))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
Button(action: {
|
|
||||||
showPreferencesSheet = true
|
|
||||||
}, label: {
|
|
||||||
Image(systemName: "gear")
|
|
||||||
.font(.system(size: 24))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(minLength: 4)
|
|
||||||
}
|
|
||||||
.background(Color(UIColor.systemBackground).edgesIgnoringSafeArea(.bottom))
|
|
||||||
.sheet(isPresented: $showPreferencesSheet, content: {
|
|
||||||
PreferencesView(presented: $showPreferencesSheet)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ToolBar_Previews: PreviewProvider {
|
|
||||||
static var previews: some View {
|
|
||||||
ToolBar(navigator: NavigationManager(url: URL(string: "gemini://localhost/overview.gmi")!), shareCurrentURL: {})
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,211 @@
|
||||||
|
//
|
||||||
|
// ToolbarView.swift
|
||||||
|
// Gemini-iOS
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/19/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
import BrowserCore
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
class ToolbarView: UIView {
|
||||||
|
|
||||||
|
let navigator: NavigationManager
|
||||||
|
|
||||||
|
var showTableOfContents: (() -> Void)?
|
||||||
|
var showShareSheet: ((UIView) -> Void)?
|
||||||
|
var showPreferences: (() -> Void)?
|
||||||
|
|
||||||
|
private var border: UIView!
|
||||||
|
private var backButton: UIButton!
|
||||||
|
private var forwardsButton: UIButton!
|
||||||
|
private var reloadButton: UIButton!
|
||||||
|
private var tableOfContentsButton: UIButton!
|
||||||
|
private var shareButton: UIButton!
|
||||||
|
private var prefsButton: UIButton!
|
||||||
|
|
||||||
|
private var cancellables = [AnyCancellable]()
|
||||||
|
|
||||||
|
init(navigator: NavigationManager) {
|
||||||
|
self.navigator = navigator
|
||||||
|
|
||||||
|
super.init(frame: .zero)
|
||||||
|
|
||||||
|
backgroundColor = .systemBackground
|
||||||
|
|
||||||
|
border = UIView()
|
||||||
|
border.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
border.backgroundColor = UIColor(white: traitCollection.userInterfaceStyle == .dark ? 0.25 : 0.75, alpha: 1)
|
||||||
|
addSubview(border)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
border.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
border.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
border.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
border.heightAnchor.constraint(equalToConstant: 1),
|
||||||
|
])
|
||||||
|
|
||||||
|
let symbolConfig = UIImage.SymbolConfiguration(pointSize: 24)
|
||||||
|
|
||||||
|
backButton = UIButton()
|
||||||
|
backButton.addTarget(navigator, action: #selector(NavigationManager.goBack), for: .touchUpInside)
|
||||||
|
backButton.isEnabled = navigator.backStack.count > 0
|
||||||
|
backButton.setImage(UIImage(systemName: "arrow.left", withConfiguration: symbolConfig), for: .normal)
|
||||||
|
backButton.accessibilityLabel = "Back"
|
||||||
|
backButton.isPointerInteractionEnabled = true
|
||||||
|
// fallback for when UIButton.menu isn't available
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
} else {
|
||||||
|
backButton.addInteraction(UIContextMenuInteraction(delegate: self))
|
||||||
|
}
|
||||||
|
|
||||||
|
forwardsButton = UIButton()
|
||||||
|
forwardsButton.addTarget(navigator, action: #selector(NavigationManager.goForward), for: .touchUpInside)
|
||||||
|
forwardsButton.isEnabled = navigator.forwardStack.count > 0
|
||||||
|
forwardsButton.setImage(UIImage(systemName: "arrow.right", withConfiguration: symbolConfig), for: .normal)
|
||||||
|
forwardsButton.accessibilityLabel = "Forward"
|
||||||
|
forwardsButton.isPointerInteractionEnabled = true
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
} else {
|
||||||
|
forwardsButton.addInteraction(UIContextMenuInteraction(delegate: self))
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadButton = UIButton()
|
||||||
|
reloadButton.addTarget(navigator, action: #selector(NavigationManager.reload), for: .touchUpInside)
|
||||||
|
reloadButton.setImage(UIImage(systemName: "arrow.clockwise", withConfiguration: symbolConfig), for: .normal)
|
||||||
|
reloadButton.accessibilityLabel = "Reload"
|
||||||
|
reloadButton.isPointerInteractionEnabled = true
|
||||||
|
|
||||||
|
tableOfContentsButton = UIButton()
|
||||||
|
tableOfContentsButton.addTarget(self, action: #selector(tableOfContentsPressed), for: .touchUpInside)
|
||||||
|
tableOfContentsButton.setImage(UIImage(systemName: "list.bullet.indent", withConfiguration: symbolConfig), for: .normal)
|
||||||
|
tableOfContentsButton.accessibilityLabel = "Table of Contents"
|
||||||
|
tableOfContentsButton.isPointerInteractionEnabled = true
|
||||||
|
|
||||||
|
shareButton = UIButton()
|
||||||
|
shareButton.addTarget(self, action: #selector(sharePressed), for: .touchUpInside)
|
||||||
|
shareButton.setImage(UIImage(systemName: "square.and.arrow.up", withConfiguration: symbolConfig), for: .normal)
|
||||||
|
shareButton.accessibilityLabel = "Share"
|
||||||
|
shareButton.isPointerInteractionEnabled = true
|
||||||
|
|
||||||
|
prefsButton = UIButton()
|
||||||
|
prefsButton.addTarget(self, action: #selector(prefsPressed), for: .touchUpInside)
|
||||||
|
prefsButton.setImage(UIImage(systemName: "gear", withConfiguration: symbolConfig), for: .normal)
|
||||||
|
prefsButton.accessibilityLabel = "Preferences"
|
||||||
|
prefsButton.isPointerInteractionEnabled = true
|
||||||
|
|
||||||
|
let stack = UIStackView(arrangedSubviews: [
|
||||||
|
backButton,
|
||||||
|
forwardsButton,
|
||||||
|
reloadButton,
|
||||||
|
tableOfContentsButton,
|
||||||
|
shareButton,
|
||||||
|
prefsButton,
|
||||||
|
])
|
||||||
|
stack.axis = .horizontal
|
||||||
|
stack.distribution = .fillEqually
|
||||||
|
stack.alignment = .fill
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
addSubview(stack)
|
||||||
|
let safeAreaConstraint = stack.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor)
|
||||||
|
safeAreaConstraint.priority = .defaultHigh
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
stack.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
stack.topAnchor.constraint(equalTo: topAnchor, constant: 5),
|
||||||
|
safeAreaConstraint,
|
||||||
|
stack.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -8)
|
||||||
|
])
|
||||||
|
|
||||||
|
updateNavigationButtons()
|
||||||
|
|
||||||
|
navigator.navigationOperation
|
||||||
|
.sink { (_) in
|
||||||
|
self.updateNavigationButtons()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
||||||
|
super.traitCollectionDidChange(previousTraitCollection)
|
||||||
|
|
||||||
|
border.backgroundColor = UIColor(white: traitCollection.userInterfaceStyle == .dark ? 0.25 : 0.75, alpha: 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func urlForDisplay(_ url: URL) -> String {
|
||||||
|
var str = url.host!
|
||||||
|
if let port = url.port,
|
||||||
|
url.scheme != "gemini" || port != 1965 {
|
||||||
|
str += ":\(port)"
|
||||||
|
}
|
||||||
|
str += url.path
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateNavigationButtons() {
|
||||||
|
backButton.isEnabled = navigator.backStack.count > 0
|
||||||
|
forwardsButton.isEnabled = navigator.forwardStack.count > 0
|
||||||
|
|
||||||
|
if #available(iOS 14.0, *) {
|
||||||
|
let back = navigator.backStack.suffix(5).enumerated().reversed().map { (index, url) -> UIAction in
|
||||||
|
let backCount = min(5, navigator.backStack.count) - index
|
||||||
|
return UIAction(title: urlForDisplay(url)) { (_) in
|
||||||
|
self.navigator.back(count: backCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backButton.menu = UIMenu(children: back)
|
||||||
|
|
||||||
|
let forward = navigator.forwardStack.prefix(5).enumerated().map { (index, url) -> UIAction in
|
||||||
|
let forwardCount = index + 1
|
||||||
|
return UIAction(title: urlForDisplay(url)) { (_) in
|
||||||
|
self.navigator.forward(count: forwardCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
forwardsButton.menu = UIMenu(children: forward)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func tableOfContentsPressed() {
|
||||||
|
showTableOfContents?()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func sharePressed() {
|
||||||
|
showShareSheet?(shareButton)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func prefsPressed() {
|
||||||
|
showPreferences?()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ToolbarView: UIContextMenuInteractionDelegate {
|
||||||
|
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
|
||||||
|
if interaction.view == backButton {
|
||||||
|
return UIContextMenuConfiguration(identifier: nil, previewProvider: { nil }) { (_) -> UIMenu? in
|
||||||
|
let children = self.navigator.backStack.suffix(5).enumerated().map { (index, url) in
|
||||||
|
UIAction(title: self.urlForDisplay(url)) { (_) in
|
||||||
|
self.navigator.back(count: min(5, self.navigator.backStack.count) - index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: children)
|
||||||
|
}
|
||||||
|
} else if interaction.view == forwardsButton {
|
||||||
|
return UIContextMenuConfiguration(identifier: nil, previewProvider: { nil }) { (_) -> UIMenu? in
|
||||||
|
let children = self.navigator.forwardStack.prefix(5).enumerated().map { (index, url) -> UIAction in
|
||||||
|
let forwardCount = index + 1
|
||||||
|
return UIAction(title: self.urlForDisplay(url)) { (_) in
|
||||||
|
self.navigator.forward(count: forwardCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UIMenu(title: "", image: nil, identifier: nil, options: [], children: children)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,86 @@
|
||||||
|
//
|
||||||
|
// UIViewController+Children.swift
|
||||||
|
// Gemini-iOS
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/17/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
// Based on MVCTodo by Dave DeLong: https://github.com/davedelong/MVCTodo/blob/841649dd6aa31bacda3ad7ef9a9a836f66281e50/MVCTodo/Extensions/UIViewController.swift
|
||||||
|
extension UIViewController {
|
||||||
|
func embedChild(_ newChild: UIViewController, in container: UIView? = nil) {
|
||||||
|
// if the view controller is already a child of something else, remove it
|
||||||
|
if let oldParent = newChild.parent, oldParent != self {
|
||||||
|
newChild.beginAppearanceTransition(false, animated: false)
|
||||||
|
newChild.willMove(toParent: nil)
|
||||||
|
newChild.removeFromParent()
|
||||||
|
|
||||||
|
if newChild.viewIfLoaded?.superview != nil {
|
||||||
|
newChild.viewIfLoaded?.removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
newChild.endAppearanceTransition()
|
||||||
|
}
|
||||||
|
|
||||||
|
// since .view returns an IUO, by default the type of this is "UIView?"
|
||||||
|
// explicitly type the variable because We Know Better™
|
||||||
|
var targetContainer: UIView = container ?? self.view
|
||||||
|
if !targetContainer.isContainedWithin(view) {
|
||||||
|
targetContainer = view
|
||||||
|
}
|
||||||
|
|
||||||
|
// add the view controller as a child
|
||||||
|
if newChild.parent != self {
|
||||||
|
newChild.beginAppearanceTransition(true, animated: false)
|
||||||
|
addChild(newChild)
|
||||||
|
newChild.didMove(toParent: self)
|
||||||
|
targetContainer.embedSubview(newChild.view)
|
||||||
|
newChild.endAppearanceTransition()
|
||||||
|
} else {
|
||||||
|
// the view controller is already a child
|
||||||
|
// make sure it's in the right view
|
||||||
|
|
||||||
|
// we don't do the appearance transition stuff here,
|
||||||
|
// because the vc is already a child, so *presumably*
|
||||||
|
// that transition stuff has already appened
|
||||||
|
targetContainer.embedSubview(newChild.view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeViewAndController() {
|
||||||
|
view.removeFromSuperview()
|
||||||
|
removeFromParent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Based on MVCTodo by Dave DeLong: https://github.com/davedelong/MVCTodo/blob/841649dd6aa31bacda3ad7ef9a9a836f66281e50/MVCTodo/Extensions/UIView.swift
|
||||||
|
extension UIView {
|
||||||
|
func embedSubview(_ subview: UIView) {
|
||||||
|
if subview.superview == self { return }
|
||||||
|
|
||||||
|
if subview.superview != nil {
|
||||||
|
subview.removeFromSuperview()
|
||||||
|
}
|
||||||
|
|
||||||
|
subview.frame = bounds
|
||||||
|
addSubview(subview)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
subview.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
subview.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func isContainedWithin(_ other: UIView) -> Bool {
|
||||||
|
var current: UIView? = self
|
||||||
|
while let proposedView = current {
|
||||||
|
if proposedView == other { return true }
|
||||||
|
current = proposedView.superview
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 50;
|
objectVersion = 52;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
@ -39,19 +39,29 @@
|
||||||
D664673624BD07F700B0B741 /* RenderingBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673524BD07F700B0B741 /* RenderingBlock.swift */; };
|
D664673624BD07F700B0B741 /* RenderingBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673524BD07F700B0B741 /* RenderingBlock.swift */; };
|
||||||
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673724BD086F00B0B741 /* RenderingBlockView.swift */; };
|
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673724BD086F00B0B741 /* RenderingBlockView.swift */; };
|
||||||
D664673A24BD0B8E00B0B741 /* Fonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673924BD0B8E00B0B741 /* Fonts.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 */; };
|
||||||
|
D688F633258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */; };
|
||||||
|
D688F64A258C17F3003A0A73 /* SymbolCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F649258C17F3003A0A73 /* SymbolCache.swift */; };
|
||||||
|
D688F65A258C2256003A0A73 /* BrowserNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F659258C2256003A0A73 /* BrowserNavigationController.swift */; };
|
||||||
|
D688F663258C2479003A0A73 /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F662258C2479003A0A73 /* UIViewController+Children.swift */; };
|
||||||
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A64D25217C6F00348C4B /* Preferences.swift */; };
|
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A64D25217C6F00348C4B /* Preferences.swift */; };
|
||||||
D691A66725217FD800348C4B /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A66625217FD800348C4B /* PreferencesView.swift */; };
|
D691A66725217FD800348C4B /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A66625217FD800348C4B /* PreferencesView.swift */; };
|
||||||
D691A6772522382E00348C4B /* BrowserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A6762522382E00348C4B /* BrowserViewController.swift */; };
|
|
||||||
D691A68725223A4700348C4B /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A68625223A4600348C4B /* NavigationBar.swift */; };
|
|
||||||
D691A6A0252242FC00348C4B /* ToolBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A69F252242FC00348C4B /* ToolBar.swift */; };
|
|
||||||
D69F00AC24BE9DD300E37622 /* GeminiDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */; };
|
D69F00AC24BE9DD300E37622 /* GeminiDataTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */; };
|
||||||
D69F00AE24BEA29100E37622 /* GeminiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AD24BEA29100E37622 /* GeminiResponse.swift */; };
|
D69F00AE24BEA29100E37622 /* GeminiResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D69F00AD24BEA29100E37622 /* GeminiResponse.swift */; };
|
||||||
|
D6BC9AB3258E8E13008652BC /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9AB2258E8E13008652BC /* ToolbarView.swift */; };
|
||||||
|
D6BC9ABC258E9862008652BC /* NavigationBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9ABB258E9862008652BC /* NavigationBarView.swift */; };
|
||||||
|
D6BC9AC5258F01F6008652BC /* TableOfContents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9AC4258F01F6008652BC /* TableOfContents.swift */; };
|
||||||
|
D6BC9ACE258F07BC008652BC /* TableOfContentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9ACD258F07BC008652BC /* TableOfContentsTests.swift */; };
|
||||||
|
D6BC9AD7258FC8B3008652BC /* TableOfContentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */; };
|
||||||
|
D6DA5783252396030048B65A /* View+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6DA5782252396030048B65A /* View+Extensions.swift */; };
|
||||||
D6E1529824BFAAA400FDF9D3 /* BrowserWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */; };
|
D6E1529824BFAAA400FDF9D3 /* BrowserWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */; };
|
||||||
D6E1529924BFAAA400FDF9D3 /* BrowserWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */; };
|
D6E1529924BFAAA400FDF9D3 /* BrowserWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */; };
|
||||||
D6E1529B24BFAEC700FDF9D3 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E1529A24BFAEC700FDF9D3 /* MainMenu.xib */; };
|
D6E1529B24BFAEC700FDF9D3 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E1529A24BFAEC700FDF9D3 /* MainMenu.xib */; };
|
||||||
D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E152A424BFFDF500FDF9D3 /* AppDelegate.swift */; };
|
D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E152A424BFFDF500FDF9D3 /* AppDelegate.swift */; };
|
||||||
D6E152A724BFFDF500FDF9D3 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E152A624BFFDF500FDF9D3 /* SceneDelegate.swift */; };
|
D6E152A724BFFDF500FDF9D3 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E152A624BFFDF500FDF9D3 /* SceneDelegate.swift */; };
|
||||||
D6E152A924BFFDF500FDF9D3 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E152A824BFFDF500FDF9D3 /* ContentView.swift */; };
|
|
||||||
D6E152AB24BFFDF600FDF9D3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */; };
|
D6E152AB24BFFDF600FDF9D3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */; };
|
||||||
D6E152AE24BFFDF600FDF9D3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E152AD24BFFDF600FDF9D3 /* Preview Assets.xcassets */; };
|
D6E152AE24BFFDF600FDF9D3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E152AD24BFFDF600FDF9D3 /* Preview Assets.xcassets */; };
|
||||||
D6E152B124BFFDF600FDF9D3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6E152AF24BFFDF600FDF9D3 /* LaunchScreen.storyboard */; };
|
D6E152B124BFFDF600FDF9D3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D6E152AF24BFFDF600FDF9D3 /* LaunchScreen.storyboard */; };
|
||||||
|
@ -296,21 +306,30 @@
|
||||||
D664673524BD07F700B0B741 /* RenderingBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlock.swift; sourceTree = "<group>"; };
|
D664673524BD07F700B0B741 /* RenderingBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlock.swift; sourceTree = "<group>"; };
|
||||||
D664673724BD086F00B0B741 /* RenderingBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlockView.swift; sourceTree = "<group>"; };
|
D664673724BD086F00B0B741 /* RenderingBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlockView.swift; sourceTree = "<group>"; };
|
||||||
D664673924BD0B8E00B0B741 /* Fonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fonts.swift; sourceTree = "<group>"; };
|
D664673924BD0B8E00B0B741 /* Fonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fonts.swift; sourceTree = "<group>"; };
|
||||||
|
D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiHTMLRenderer.swift; sourceTree = "<group>"; };
|
||||||
|
D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWebViewController.swift; sourceTree = "<group>"; };
|
||||||
|
D688F5FE258ACE6B003A0A73 /* browser.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = browser.css; sourceTree = "<group>"; };
|
||||||
|
D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
|
||||||
|
D688F649258C17F3003A0A73 /* SymbolCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymbolCache.swift; sourceTree = "<group>"; };
|
||||||
|
D688F659258C2256003A0A73 /* BrowserNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserNavigationController.swift; sourceTree = "<group>"; };
|
||||||
|
D688F662258C2479003A0A73 /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
|
||||||
D691A64D25217C6F00348C4B /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
|
D691A64D25217C6F00348C4B /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
|
||||||
D691A66625217FD800348C4B /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
|
D691A66625217FD800348C4B /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
|
||||||
D691A6762522382E00348C4B /* BrowserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserViewController.swift; sourceTree = "<group>"; };
|
|
||||||
D691A68625223A4600348C4B /* NavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = "<group>"; };
|
|
||||||
D691A69F252242FC00348C4B /* ToolBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolBar.swift; sourceTree = "<group>"; };
|
|
||||||
D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiDataTask.swift; sourceTree = "<group>"; };
|
D69F00AB24BE9DD300E37622 /* GeminiDataTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiDataTask.swift; sourceTree = "<group>"; };
|
||||||
D69F00AD24BEA29100E37622 /* GeminiResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiResponse.swift; sourceTree = "<group>"; };
|
D69F00AD24BEA29100E37622 /* GeminiResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiResponse.swift; sourceTree = "<group>"; };
|
||||||
D69F00AF24BEA84D00E37622 /* NavigationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationManager.swift; sourceTree = "<group>"; };
|
D69F00AF24BEA84D00E37622 /* NavigationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationManager.swift; sourceTree = "<group>"; };
|
||||||
|
D6BC9AB2258E8E13008652BC /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = "<group>"; };
|
||||||
|
D6BC9ABB258E9862008652BC /* NavigationBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarView.swift; sourceTree = "<group>"; };
|
||||||
|
D6BC9AC4258F01F6008652BC /* TableOfContents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContents.swift; sourceTree = "<group>"; };
|
||||||
|
D6BC9ACD258F07BC008652BC /* TableOfContentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsTests.swift; sourceTree = "<group>"; };
|
||||||
|
D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableOfContentsView.swift; sourceTree = "<group>"; };
|
||||||
|
D6DA5782252396030048B65A /* View+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowController.swift; sourceTree = "<group>"; };
|
D6E1529624BFAAA400FDF9D3 /* BrowserWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowController.swift; sourceTree = "<group>"; };
|
||||||
D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BrowserWindowController.xib; sourceTree = "<group>"; };
|
D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BrowserWindowController.xib; sourceTree = "<group>"; };
|
||||||
D6E1529A24BFAEC700FDF9D3 /* MainMenu.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
|
D6E1529A24BFAEC700FDF9D3 /* MainMenu.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = MainMenu.xib; sourceTree = "<group>"; };
|
||||||
D6E152A224BFFDF500FDF9D3 /* Gemini-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Gemini-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
D6E152A224BFFDF500FDF9D3 /* Gemini-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Gemini-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
D6E152A424BFFDF500FDF9D3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
D6E152A424BFFDF500FDF9D3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
D6E152A624BFFDF500FDF9D3 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
D6E152A624BFFDF500FDF9D3 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
D6E152A824BFFDF500FDF9D3 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
|
||||||
D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
D6E152AD24BFFDF600FDF9D3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
D6E152AD24BFFDF600FDF9D3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||||
D6E152B024BFFDF600FDF9D3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
D6E152B024BFFDF600FDF9D3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
|
@ -370,6 +389,7 @@
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
D688F590258AC814003A0A73 /* HTMLEntities in Frameworks */,
|
||||||
D62664F024BC0D7700DF9B88 /* GeminiFormat.framework in Frameworks */,
|
D62664F024BC0D7700DF9B88 /* GeminiFormat.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -502,6 +522,7 @@
|
||||||
D62664AB24BBF26A00DF9B88 /* Info.plist */,
|
D62664AB24BBF26A00DF9B88 /* Info.plist */,
|
||||||
D62664C724BBF2C600DF9B88 /* Document.swift */,
|
D62664C724BBF2C600DF9B88 /* Document.swift */,
|
||||||
D62664C524BBF27300DF9B88 /* GeminiParser.swift */,
|
D62664C524BBF27300DF9B88 /* GeminiParser.swift */,
|
||||||
|
D6BC9AC4258F01F6008652BC /* TableOfContents.swift */,
|
||||||
);
|
);
|
||||||
path = GeminiFormat;
|
path = GeminiFormat;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -511,6 +532,7 @@
|
||||||
children = (
|
children = (
|
||||||
D62664F924BC12BC00DF9B88 /* DocumentTests.swift */,
|
D62664F924BC12BC00DF9B88 /* DocumentTests.swift */,
|
||||||
D62664B724BBF26A00DF9B88 /* GeminiParserTests.swift */,
|
D62664B724BBF26A00DF9B88 /* GeminiParserTests.swift */,
|
||||||
|
D6BC9ACD258F07BC008652BC /* TableOfContentsTests.swift */,
|
||||||
D62664B924BBF26A00DF9B88 /* Info.plist */,
|
D62664B924BBF26A00DF9B88 /* Info.plist */,
|
||||||
);
|
);
|
||||||
path = GeminiFormatTests;
|
path = GeminiFormatTests;
|
||||||
|
@ -526,6 +548,8 @@
|
||||||
D62664ED24BC0BCE00DF9B88 /* MaybeLazyVStack.swift */,
|
D62664ED24BC0BCE00DF9B88 /* MaybeLazyVStack.swift */,
|
||||||
D62664EB24BC0B4D00DF9B88 /* DocumentView.swift */,
|
D62664EB24BC0B4D00DF9B88 /* DocumentView.swift */,
|
||||||
D664673724BD086F00B0B741 /* RenderingBlockView.swift */,
|
D664673724BD086F00B0B741 /* RenderingBlockView.swift */,
|
||||||
|
D6DA5782252396030048B65A /* View+Extensions.swift */,
|
||||||
|
D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */,
|
||||||
);
|
);
|
||||||
path = GeminiRenderer;
|
path = GeminiRenderer;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -546,17 +570,30 @@
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
D688F618258AD231003A0A73 /* Resources */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D688F5FE258ACE6B003A0A73 /* browser.css */,
|
||||||
|
);
|
||||||
|
path = Resources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
D6E152A324BFFDF500FDF9D3 /* Gemini-iOS */ = {
|
D6E152A324BFFDF500FDF9D3 /* Gemini-iOS */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
D6E152A424BFFDF500FDF9D3 /* AppDelegate.swift */,
|
D6E152A424BFFDF500FDF9D3 /* AppDelegate.swift */,
|
||||||
D6E152A624BFFDF500FDF9D3 /* SceneDelegate.swift */,
|
D6E152A624BFFDF500FDF9D3 /* SceneDelegate.swift */,
|
||||||
D691A6762522382E00348C4B /* BrowserViewController.swift */,
|
D688F649258C17F3003A0A73 /* SymbolCache.swift */,
|
||||||
D6E152A824BFFDF500FDF9D3 /* ContentView.swift */,
|
D688F659258C2256003A0A73 /* BrowserNavigationController.swift */,
|
||||||
D691A68625223A4600348C4B /* NavigationBar.swift */,
|
D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */,
|
||||||
D691A69F252242FC00348C4B /* ToolBar.swift */,
|
D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */,
|
||||||
|
D688F662258C2479003A0A73 /* UIViewController+Children.swift */,
|
||||||
|
D6BC9AB2258E8E13008652BC /* ToolbarView.swift */,
|
||||||
|
D6BC9ABB258E9862008652BC /* NavigationBarView.swift */,
|
||||||
|
D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */,
|
||||||
D691A64D25217C6F00348C4B /* Preferences.swift */,
|
D691A64D25217C6F00348C4B /* Preferences.swift */,
|
||||||
D691A66625217FD800348C4B /* PreferencesView.swift */,
|
D691A66625217FD800348C4B /* PreferencesView.swift */,
|
||||||
|
D688F618258AD231003A0A73 /* Resources */,
|
||||||
D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */,
|
D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */,
|
||||||
D6E152AF24BFFDF600FDF9D3 /* LaunchScreen.storyboard */,
|
D6E152AF24BFFDF600FDF9D3 /* LaunchScreen.storyboard */,
|
||||||
D6E152B224BFFDF600FDF9D3 /* Info.plist */,
|
D6E152B224BFFDF600FDF9D3 /* Info.plist */,
|
||||||
|
@ -743,6 +780,9 @@
|
||||||
D68544302522E10F004C4AE0 /* PBXTargetDependency */,
|
D68544302522E10F004C4AE0 /* PBXTargetDependency */,
|
||||||
);
|
);
|
||||||
name = GeminiRenderer;
|
name = GeminiRenderer;
|
||||||
|
packageProductDependencies = (
|
||||||
|
D688F58F258AC814003A0A73 /* HTMLEntities */,
|
||||||
|
);
|
||||||
productName = GeminiRenderer;
|
productName = GeminiRenderer;
|
||||||
productReference = D62664CE24BC081B00DF9B88 /* GeminiRenderer.framework */;
|
productReference = D62664CE24BC081B00DF9B88 /* GeminiRenderer.framework */;
|
||||||
productType = "com.apple.product-type.framework";
|
productType = "com.apple.product-type.framework";
|
||||||
|
@ -885,6 +925,9 @@
|
||||||
Base,
|
Base,
|
||||||
);
|
);
|
||||||
mainGroup = D626645224BBF1C200DF9B88;
|
mainGroup = D626645224BBF1C200DF9B88;
|
||||||
|
packageReferences = (
|
||||||
|
D688F58E258AC814003A0A73 /* XCRemoteSwiftPackageReference "swift-html-entities" */,
|
||||||
|
);
|
||||||
productRefGroup = D626645C24BBF1C200DF9B88 /* Products */;
|
productRefGroup = D626645C24BBF1C200DF9B88 /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
projectRoot = "";
|
projectRoot = "";
|
||||||
|
@ -963,6 +1006,7 @@
|
||||||
files = (
|
files = (
|
||||||
D6E152B124BFFDF600FDF9D3 /* LaunchScreen.storyboard in Resources */,
|
D6E152B124BFFDF600FDF9D3 /* LaunchScreen.storyboard in Resources */,
|
||||||
D6E152AE24BFFDF600FDF9D3 /* Preview Assets.xcassets in Resources */,
|
D6E152AE24BFFDF600FDF9D3 /* Preview Assets.xcassets in Resources */,
|
||||||
|
D688F5FF258ACE6B003A0A73 /* browser.css in Resources */,
|
||||||
D6E152AB24BFFDF600FDF9D3 /* Assets.xcassets in Resources */,
|
D6E152AB24BFFDF600FDF9D3 /* Assets.xcassets in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -1022,6 +1066,7 @@
|
||||||
files = (
|
files = (
|
||||||
D62664C824BBF2C600DF9B88 /* Document.swift in Sources */,
|
D62664C824BBF2C600DF9B88 /* Document.swift in Sources */,
|
||||||
D62664C624BBF27300DF9B88 /* GeminiParser.swift in Sources */,
|
D62664C624BBF27300DF9B88 /* GeminiParser.swift in Sources */,
|
||||||
|
D6BC9AC5258F01F6008652BC /* TableOfContents.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -1030,6 +1075,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D62664FA24BC12BC00DF9B88 /* DocumentTests.swift in Sources */,
|
D62664FA24BC12BC00DF9B88 /* DocumentTests.swift in Sources */,
|
||||||
|
D6BC9ACE258F07BC008652BC /* TableOfContentsTests.swift in Sources */,
|
||||||
D62664B824BBF26A00DF9B88 /* GeminiParserTests.swift in Sources */,
|
D62664B824BBF26A00DF9B88 /* GeminiParserTests.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -1042,6 +1088,8 @@
|
||||||
D664673A24BD0B8E00B0B741 /* Fonts.swift in Sources */,
|
D664673A24BD0B8E00B0B741 /* Fonts.swift in Sources */,
|
||||||
D62664EE24BC0BCE00DF9B88 /* MaybeLazyVStack.swift in Sources */,
|
D62664EE24BC0BCE00DF9B88 /* MaybeLazyVStack.swift in Sources */,
|
||||||
D62664EC24BC0B4D00DF9B88 /* DocumentView.swift in Sources */,
|
D62664EC24BC0B4D00DF9B88 /* DocumentView.swift in Sources */,
|
||||||
|
D6DA5783252396030048B65A /* View+Extensions.swift in Sources */,
|
||||||
|
D688F586258AC738003A0A73 /* GeminiHTMLRenderer.swift in Sources */,
|
||||||
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */,
|
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -1059,13 +1107,17 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
D691A66725217FD800348C4B /* PreferencesView.swift in Sources */,
|
D691A66725217FD800348C4B /* PreferencesView.swift in Sources */,
|
||||||
D691A6772522382E00348C4B /* BrowserViewController.swift in Sources */,
|
D688F599258ACAAE003A0A73 /* BrowserWebViewController.swift in Sources */,
|
||||||
|
D688F633258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift in Sources */,
|
||||||
D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */,
|
D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */,
|
||||||
D691A6A0252242FC00348C4B /* ToolBar.swift in Sources */,
|
|
||||||
D6E152A724BFFDF500FDF9D3 /* SceneDelegate.swift in Sources */,
|
D6E152A724BFFDF500FDF9D3 /* SceneDelegate.swift in Sources */,
|
||||||
D691A68725223A4700348C4B /* NavigationBar.swift in Sources */,
|
D6BC9AB3258E8E13008652BC /* ToolbarView.swift in Sources */,
|
||||||
|
D688F64A258C17F3003A0A73 /* SymbolCache.swift in Sources */,
|
||||||
|
D688F65A258C2256003A0A73 /* BrowserNavigationController.swift in Sources */,
|
||||||
|
D6BC9AD7258FC8B3008652BC /* TableOfContentsView.swift in Sources */,
|
||||||
|
D688F663258C2479003A0A73 /* UIViewController+Children.swift in Sources */,
|
||||||
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */,
|
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */,
|
||||||
D6E152A924BFFDF500FDF9D3 /* ContentView.swift in Sources */,
|
D6BC9ABC258E9862008652BC /* NavigationBarView.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -1325,7 +1377,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Gemini/Gemini.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Gemini/Gemini.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "-";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Gemini/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Gemini/Preview Content\"";
|
||||||
|
@ -1351,7 +1403,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = Gemini/Gemini.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Gemini/Gemini.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "-";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Gemini/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Gemini/Preview Content\"";
|
||||||
|
@ -1692,7 +1744,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Gemini-iOS/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Gemini-iOS/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
@ -1702,7 +1754,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2020.1;
|
MARKETING_VERSION = 2021.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Gemini;
|
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Gemini;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
@ -1718,7 +1770,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 5;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"Gemini-iOS/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"Gemini-iOS/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
|
@ -1728,7 +1780,7 @@
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2020.1;
|
MARKETING_VERSION = 2021.1;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Gemini;
|
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Gemini;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
@ -1943,6 +1995,25 @@
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* 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 */;
|
rootObject = D626645324BBF1C200DF9B88 /* Project object */;
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,7 +59,7 @@
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "DEFAULT_URL"
|
key = "DEFAULT_URL"
|
||||||
value = "gemini://drewdevault.com"
|
value = "gemini://drewdevault.com"
|
||||||
isEnabled = "YES">
|
isEnabled = "NO">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
</EnvironmentVariables>
|
</EnvironmentVariables>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
<key>BrowserCore.xcscheme_^#shared#^_</key>
|
<key>BrowserCore.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>5</integer>
|
<integer>3</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>Gemini-iOS.xcscheme_^#shared#^_</key>
|
<key>Gemini-iOS.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
|
@ -27,12 +27,12 @@
|
||||||
<key>GeminiProtocol.xcscheme_^#shared#^_</key>
|
<key>GeminiProtocol.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>3</integer>
|
<integer>4</integer>
|
||||||
</dict>
|
</dict>
|
||||||
<key>GeminiRenderer.xcscheme_^#shared#^_</key>
|
<key>GeminiRenderer.xcscheme_^#shared#^_</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>orderHint</key>
|
<key>orderHint</key>
|
||||||
<integer>4</integer>
|
<integer>5</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>SuppressBuildableAutocreation</key>
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -108,8 +108,8 @@ extension BrowserWindowController: NSToolbarDelegate {
|
||||||
item.label = "Go Back"
|
item.label = "Go Back"
|
||||||
item.paletteLabel = "Go Back"
|
item.paletteLabel = "Go Back"
|
||||||
item.toolTip = "Go to the previous page"
|
item.toolTip = "Go to the previous page"
|
||||||
item.target = navigator
|
item.target = self
|
||||||
item.action = #selector(NavigationManager.back)
|
item.action = #selector(back)
|
||||||
item.isBordered = true
|
item.isBordered = true
|
||||||
if #available(macOS 10.16, *) {
|
if #available(macOS 10.16, *) {
|
||||||
item.isNavigational = true
|
item.isNavigational = true
|
||||||
|
@ -127,8 +127,8 @@ extension BrowserWindowController: NSToolbarDelegate {
|
||||||
item.label = "Go Forward"
|
item.label = "Go Forward"
|
||||||
item.paletteLabel = "Go Forward"
|
item.paletteLabel = "Go Forward"
|
||||||
item.toolTip = "Go to the next page"
|
item.toolTip = "Go to the next page"
|
||||||
item.target = navigator
|
item.target = self
|
||||||
item.action = #selector(NavigationManager.forward)
|
item.action = #selector(forward)
|
||||||
item.isBordered = true
|
item.isBordered = true
|
||||||
if #available(macOS 10.16, *) {
|
if #available(macOS 10.16, *) {
|
||||||
item.isNavigational = true
|
item.isNavigational = true
|
||||||
|
@ -136,14 +136,22 @@ extension BrowserWindowController: NSToolbarDelegate {
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func back() {
|
||||||
|
navigator.goBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NavigationManager: NSToolbarItemValidation {
|
@objc private func forward() {
|
||||||
|
navigator.goForward()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BrowserWindowController: NSToolbarItemValidation {
|
||||||
public func validateToolbarItem(_ item: NSToolbarItem) -> Bool {
|
public func validateToolbarItem(_ item: NSToolbarItem) -> Bool {
|
||||||
if item.itemIdentifier == .goBack {
|
if item.itemIdentifier == .goBack {
|
||||||
return !backStack.isEmpty
|
return !navigator.backStack.isEmpty
|
||||||
} else if item.itemIdentifier == .goForward {
|
} else if item.itemIdentifier == .goForward {
|
||||||
return !forwardStack.isEmpty
|
return !navigator.forwardStack.isEmpty
|
||||||
} else {
|
} else {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,6 +21,13 @@ public struct Document {
|
||||||
// todo: should this be \r\n
|
// todo: should this be \r\n
|
||||||
return lines.map { $0.geminiText() }.joined(separator: "\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 {
|
public extension Document {
|
||||||
|
@ -63,7 +70,7 @@ public extension Document {
|
||||||
}
|
}
|
||||||
|
|
||||||
public extension Document {
|
public extension Document {
|
||||||
enum HeadingLevel: Int {
|
enum HeadingLevel: Int, Comparable {
|
||||||
case h1 = 1, h2 = 2, h3 = 3
|
case h1 = 1, h2 = 2, h3 = 3
|
||||||
|
|
||||||
var geminiText: String {
|
var geminiText: String {
|
||||||
|
@ -76,5 +83,9 @@ public extension Document {
|
||||||
return "###"
|
return "###"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func < (lhs: Document.HeadingLevel, rhs: Document.HeadingLevel) -> Bool {
|
||||||
|
return lhs.rawValue < rhs.rawValue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,7 +39,11 @@ public struct GeminiParser {
|
||||||
let urlEnd = line.firstWhitespaceIndex(after: urlStart)
|
let urlEnd = line.firstWhitespaceIndex(after: urlStart)
|
||||||
let textStart = line.firstNonWhitespaceIndex(after: urlEnd)
|
let textStart = line.firstNonWhitespaceIndex(after: urlEnd)
|
||||||
|
|
||||||
let urlString = String(line[urlStart..<urlEnd])
|
var urlString = String(line[urlStart..<urlEnd])
|
||||||
|
if urlString.hasPrefix("//") {
|
||||||
|
// URL(string:relativeTo:) does not handle // meaning the same protocol as the base URL
|
||||||
|
urlString = baseURL.scheme! + ":" + urlString
|
||||||
|
}
|
||||||
// todo: if the URL initializer fails, should there be a .link line with a nil URL?
|
// todo: if the URL initializer fails, should there be a .link line with a nil URL?
|
||||||
let url = URL(string: urlString, relativeTo: baseURL)!.absoluteURL
|
let url = URL(string: urlString, relativeTo: baseURL)!.absoluteURL
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
//
|
||||||
|
// TableOfContents.swift
|
||||||
|
// GeminiFormat
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/19/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct TableOfContents {
|
||||||
|
public let entries: [Entry]
|
||||||
|
|
||||||
|
public init(document: Document) {
|
||||||
|
self.entries = TableOfContents.entries(lines: document.lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func entries(lines: [Document.Line]) -> [Entry] {
|
||||||
|
var topLevelEntries = [Entry]()
|
||||||
|
|
||||||
|
var currentEntries = [Entry]()
|
||||||
|
|
||||||
|
var index = 0
|
||||||
|
while index < lines.count {
|
||||||
|
defer { index += 1 }
|
||||||
|
|
||||||
|
let line = lines[index]
|
||||||
|
guard case let .heading(_, level: level) = line else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let newEntry = Entry(line: line, lineIndex: index)
|
||||||
|
|
||||||
|
while !currentEntries.isEmpty && level <= currentEntries.last!.level {
|
||||||
|
currentEntries.removeLast()
|
||||||
|
}
|
||||||
|
|
||||||
|
if let last = currentEntries.last {
|
||||||
|
last.children.append(newEntry)
|
||||||
|
currentEntries.append(newEntry)
|
||||||
|
} else {
|
||||||
|
topLevelEntries.append(newEntry)
|
||||||
|
currentEntries.append(newEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return topLevelEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension TableOfContents {
|
||||||
|
class Entry: Equatable {
|
||||||
|
public let line: Document.Line
|
||||||
|
let level: Document.HeadingLevel
|
||||||
|
public let lineIndex: Int
|
||||||
|
public fileprivate(set) var children: [Entry]
|
||||||
|
|
||||||
|
init(line: Document.Line, lineIndex: Int) {
|
||||||
|
guard case let .heading(_, level: level) = line else { fatalError() }
|
||||||
|
self.line = line
|
||||||
|
self.level = level
|
||||||
|
self.lineIndex = lineIndex
|
||||||
|
self.children = []
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func ==(lhs: Entry, rhs: Entry) -> Bool {
|
||||||
|
return lhs.line == rhs.line && lhs.lineIndex == rhs.lineIndex && lhs.children == rhs.children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,148 @@
|
||||||
|
//
|
||||||
|
// TableOfContentsTests.swift
|
||||||
|
// GeminiFormatTests
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/19/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
@testable import GeminiFormat
|
||||||
|
|
||||||
|
class TableOfContentsTests: XCTestCase {
|
||||||
|
|
||||||
|
func testOneHeading() {
|
||||||
|
let one = Document(url: URL(string: "gemini://example.com/")!, lines: [
|
||||||
|
.heading("Heading", level: .h1)
|
||||||
|
])
|
||||||
|
XCTAssertEqual(TableOfContents(document: one).entries, [
|
||||||
|
.init(line: .heading("Heading", level: .h1), lineIndex: 0)
|
||||||
|
])
|
||||||
|
|
||||||
|
let two = Document(url: URL(string: "gemini://example.com/")!, lines: [
|
||||||
|
.heading("Heading", level: .h2)
|
||||||
|
])
|
||||||
|
XCTAssertEqual(TableOfContents(document: two).entries, [
|
||||||
|
.init(line: .heading("Heading", level: .h2), lineIndex: 0)
|
||||||
|
])
|
||||||
|
|
||||||
|
let three = Document(url: URL(string: "gemini://example.com/")!, lines: [
|
||||||
|
.heading("Heading", level: .h3)
|
||||||
|
])
|
||||||
|
XCTAssertEqual(TableOfContents(document: three).entries, [
|
||||||
|
.init(line: .heading("Heading", level: .h3), lineIndex: 0)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleTopLevelHeadings() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("One", level: .h1),
|
||||||
|
.heading("Two", level: .h1),
|
||||||
|
])
|
||||||
|
XCTAssertEqual(TableOfContents(document: doc).entries, [
|
||||||
|
.init(line: .heading("One", level: .h1), lineIndex: 0),
|
||||||
|
.init(line: .heading("Two", level: .h1), lineIndex: 1)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNestedHeadings() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("One", level: .h1),
|
||||||
|
.heading("Two", level: .h2),
|
||||||
|
])
|
||||||
|
let entries = TableOfContents(document: doc).entries
|
||||||
|
XCTAssertEqual(entries.count, 1)
|
||||||
|
XCTAssertEqual(entries[0].line, .heading("One", level: .h1))
|
||||||
|
XCTAssertEqual(entries[0].lineIndex, 0)
|
||||||
|
XCTAssertEqual(entries[0].children, [
|
||||||
|
.init(line: .heading("Two", level: .h2), lineIndex: 1)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testTriplyNestedHeadings() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("One", level: .h1),
|
||||||
|
.heading("Two", level: .h2),
|
||||||
|
.heading("Three", level: .h3),
|
||||||
|
])
|
||||||
|
let entries = TableOfContents(document: doc).entries
|
||||||
|
XCTAssertEqual(entries.count, 1)
|
||||||
|
XCTAssertEqual(entries[0].line, .heading("One", level: .h1))
|
||||||
|
XCTAssertEqual(entries[0].lineIndex, 0)
|
||||||
|
XCTAssertEqual(entries[0].children.count, 1)
|
||||||
|
XCTAssertEqual(entries[0].children[0].line, .heading("Two", level: .h2))
|
||||||
|
XCTAssertEqual(entries[0].children[0].lineIndex, 1)
|
||||||
|
XCTAssertEqual(entries[0].children[0].children, [
|
||||||
|
.init(line: .heading("Three", level: .h3), lineIndex: 2)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleTopLevelSections() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("Top Level One", level: .h1),
|
||||||
|
.heading("A", level: .h2),
|
||||||
|
.heading("Top Level Two", level: .h1),
|
||||||
|
.heading("B", level: .h2),
|
||||||
|
])
|
||||||
|
let entries = TableOfContents(document: doc).entries
|
||||||
|
XCTAssertEqual(entries.count, 2)
|
||||||
|
let first = entries[0]
|
||||||
|
XCTAssertEqual(first.line, .heading("Top Level One", level: .h1))
|
||||||
|
XCTAssertEqual(first.lineIndex, 0)
|
||||||
|
XCTAssertEqual(first.children, [
|
||||||
|
.init(line: .heading("A", level: .h2), lineIndex: 1)
|
||||||
|
])
|
||||||
|
let second = entries[1]
|
||||||
|
XCTAssertEqual(second.line, .heading("Top Level Two", level: .h1))
|
||||||
|
XCTAssertEqual(second.lineIndex, 2)
|
||||||
|
XCTAssertEqual(second.children, [
|
||||||
|
.init(line: .heading("B", level: .h2), lineIndex: 3)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleNestedSections() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("Top Level", level: .h1),
|
||||||
|
.heading("A", level: .h2),
|
||||||
|
.heading("Third Level", level: .h3),
|
||||||
|
.heading("B", level: .h2),
|
||||||
|
.heading("Third Level 2", level: .h3),
|
||||||
|
])
|
||||||
|
let entries = TableOfContents(document: doc).entries
|
||||||
|
XCTAssertEqual(entries.count, 1)
|
||||||
|
let topLevel = entries[0]
|
||||||
|
XCTAssertEqual(topLevel.line, .heading("Top Level", level: .h1))
|
||||||
|
XCTAssertEqual(topLevel.lineIndex, 0)
|
||||||
|
let children = topLevel.children
|
||||||
|
XCTAssertEqual(children.count, 2)
|
||||||
|
let first = children[0]
|
||||||
|
XCTAssertEqual(first.line, .heading("A", level: .h2))
|
||||||
|
XCTAssertEqual(first.lineIndex, 1)
|
||||||
|
XCTAssertEqual(first.children, [
|
||||||
|
.init(line: .heading("Third Level", level: .h3), lineIndex: 2)
|
||||||
|
])
|
||||||
|
let second = children[1]
|
||||||
|
XCTAssertEqual(second.line, .heading("B", level: .h2))
|
||||||
|
XCTAssertEqual(second.lineIndex, 3)
|
||||||
|
XCTAssertEqual(second.children, [
|
||||||
|
.init(line: .heading("Third Level 2", level: .h3), lineIndex: 4)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNonH1TopLevelSections() {
|
||||||
|
let doc = Document(url: URL(string: "gemini://example.com")!, lines: [
|
||||||
|
.heading("A", level: .h2),
|
||||||
|
.heading("B", level: .h3),
|
||||||
|
.heading("C", level: .h1),
|
||||||
|
])
|
||||||
|
let entries = TableOfContents(document: doc).entries
|
||||||
|
XCTAssertEqual(entries.count, 2)
|
||||||
|
let first = entries[0]
|
||||||
|
XCTAssertEqual(first.line, .heading("A", level: .h2))
|
||||||
|
XCTAssertEqual(first.lineIndex, 0)
|
||||||
|
XCTAssertEqual(first.children, [
|
||||||
|
.init(line: .heading("B", level: .h3), lineIndex: 1)
|
||||||
|
])
|
||||||
|
XCTAssertEqual(entries[1], .init(line: .heading("C", level: .h1), lineIndex: 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -88,12 +88,14 @@ class GeminiProtocol: NWProtocolFramerImplementation {
|
||||||
let header = GeminiResponseHeader(status: statusCode, meta: meta)
|
let header = GeminiResponseHeader(status: statusCode, meta: meta)
|
||||||
|
|
||||||
let message = NWProtocolFramer.Message(geminiResponseHeader: header)
|
let message = NWProtocolFramer.Message(geminiResponseHeader: header)
|
||||||
while true {
|
// What does the return value of deliverInputNoCopy mean, you ask? Why, I have no idea
|
||||||
if !framer.deliverInputNoCopy(length: .max, message: message, isComplete: true) {
|
// It always returns true for a length of zero, so following the sample code and looping
|
||||||
|
// infinitely until it returns false causes an infinite loop.
|
||||||
|
// Additionally, calling deliverInput with an empty Data() causes an error inside Network.framework.
|
||||||
|
// So, we just ignore the result since it doesn't seem to cause any problems ¯\_(ツ)_/¯
|
||||||
|
_ = framer.deliverInputNoCopy(length: statusCode.isSuccess ? .max : 0, message: message, isComplete: true)
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleOutput(framer: NWProtocolFramer.Instance, message: NWProtocolFramer.Message, messageLength: Int, isComplete: Bool) {
|
func handleOutput(framer: NWProtocolFramer.Instance, message: NWProtocolFramer.Message, messageLength: Int, isComplete: Bool) {
|
||||||
guard let request = message.geminiRequest else { fatalError("GeminiProtocol can't send message that doesn't have an associated GeminiRequest") }
|
guard let request = message.geminiRequest else { fatalError("GeminiProtocol can't send message that doesn't have an associated GeminiRequest") }
|
||||||
|
|
|
@ -35,7 +35,7 @@ public struct DocumentView: View {
|
||||||
private var scrollBody: some View {
|
private var scrollBody: some View {
|
||||||
MaybeLazyVStack(alignment: .leading) {
|
MaybeLazyVStack(alignment: .leading) {
|
||||||
ForEach(blocks.indices) { (index) in
|
ForEach(blocks.indices) { (index) in
|
||||||
RenderingBlockView(block: blocks[index], changeURL: changeURL)
|
RenderingBlockView(document: document, block: blocks[index], changeURL: changeURL)
|
||||||
}
|
}
|
||||||
}.padding([.leading, .trailing, .bottom])
|
}.padding([.leading, .trailing, .bottom])
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,75 @@
|
||||||
|
//
|
||||||
|
// GeminiHTMLRenderer.swift
|
||||||
|
// GeminiRenderer
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 12/16/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import GeminiFormat
|
||||||
|
import HTMLEntities
|
||||||
|
|
||||||
|
public class GeminiHTMLRenderer {
|
||||||
|
|
||||||
|
public var linkPrefix: ((URL) -> String?)?
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public func renderDocumentToHTML(_ doc: Document) -> String {
|
||||||
|
var str = ""
|
||||||
|
|
||||||
|
var inPreformatting = false
|
||||||
|
var inList = false
|
||||||
|
|
||||||
|
for (index, line) in doc.lines.enumerated() {
|
||||||
|
if inList && !line.isListItem {
|
||||||
|
str += "</ul>"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch line {
|
||||||
|
case let .text(text):
|
||||||
|
str += "<p>\(text.htmlEscape())</p>"
|
||||||
|
case let .link(url, text: maybeText):
|
||||||
|
let text = maybeText ?? url.absoluteString
|
||||||
|
let linkPrefix = self.linkPrefix?(url) ?? ""
|
||||||
|
str += "<p class=\"link\">\(linkPrefix)<a href=\"\(url.absoluteString)\">\(text.htmlEscape())</a></p>"
|
||||||
|
case .preformattedToggle(alt: _):
|
||||||
|
inPreformatting = !inPreformatting
|
||||||
|
if inPreformatting {
|
||||||
|
str += "<pre>"
|
||||||
|
} else {
|
||||||
|
str += "</pre>"
|
||||||
|
}
|
||||||
|
case let .preformattedText(text):
|
||||||
|
str += text.htmlEscape()
|
||||||
|
str += "\n"
|
||||||
|
case let .heading(text, level: level):
|
||||||
|
let tag = "h\(level.rawValue)"
|
||||||
|
str += "<\(tag) id=\"l\(index)\">\(text.htmlEscape())</\(tag)>"
|
||||||
|
case let .unorderedListItem(text):
|
||||||
|
if !inList {
|
||||||
|
inList = true
|
||||||
|
str += "<ul>"
|
||||||
|
}
|
||||||
|
str += "<li>\(text.htmlEscape())</li>"
|
||||||
|
case let .quote(text):
|
||||||
|
str += "<blockquote>\(text.htmlEscape())</blockquote>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate extension Document.Line {
|
||||||
|
var isListItem: Bool {
|
||||||
|
switch self {
|
||||||
|
case .unorderedListItem(_):
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,11 +9,15 @@ import SwiftUI
|
||||||
import GeminiFormat
|
import GeminiFormat
|
||||||
|
|
||||||
struct RenderingBlockView: View {
|
struct RenderingBlockView: View {
|
||||||
|
let document: Document
|
||||||
let block: RenderingBlock
|
let block: RenderingBlock
|
||||||
let changeURL: ((URL) -> Void)?
|
let changeURL: ((URL) -> Void)?
|
||||||
@State var hovering = false
|
@State var hovering = false
|
||||||
|
|
||||||
init(block: RenderingBlock, changeURL: ((URL) -> Void)? = nil) {
|
@Environment(\.colorScheme) var colorScheme: ColorScheme
|
||||||
|
|
||||||
|
init(document: Document, block: RenderingBlock, changeURL: ((URL) -> Void)? = nil) {
|
||||||
|
self.document = document
|
||||||
self.block = block
|
self.block = block
|
||||||
self.changeURL = changeURL
|
self.changeURL = changeURL
|
||||||
}
|
}
|
||||||
|
@ -37,7 +41,7 @@ struct RenderingBlockView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func text(_ text: String) -> some View {
|
private func text(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(verbatim: text)
|
||||||
.font(.documentBody)
|
.font(.documentBody)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
@ -51,24 +55,50 @@ struct RenderingBlockView: View {
|
||||||
let buttonStyle = PlainButtonStyle()
|
let buttonStyle = PlainButtonStyle()
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
let imageName: String
|
||||||
|
if url.scheme == "gemini" {
|
||||||
|
if url.host == document.url.host {
|
||||||
|
imageName = "arrow.right"
|
||||||
|
} else {
|
||||||
|
imageName = "link"
|
||||||
|
}
|
||||||
|
} else if url.scheme == "http" || url.scheme == "https" {
|
||||||
|
imageName = "safari"
|
||||||
|
} else if url.scheme == "mailto" {
|
||||||
|
imageName = "envelope"
|
||||||
|
} else {
|
||||||
|
imageName = "arrow.up.left.square"
|
||||||
|
}
|
||||||
|
|
||||||
let button: some View = Button {
|
let button: some View = Button {
|
||||||
self.changeURL?(url)
|
self.changeURL?(url)
|
||||||
} label: {
|
} label: {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||||
|
maybeLinkImage(name: imageName)
|
||||||
|
|
||||||
Text(verbatim: text)
|
Text(verbatim: text)
|
||||||
.font(.documentBody)
|
.font(.documentBody)
|
||||||
.foregroundColor(hovering ? .blue : Color.blue.opacity(0.8))
|
.foregroundColor(colorScheme == .dark ?
|
||||||
|
hovering ? Color.blue.opacity(0.8) : .blue :
|
||||||
|
hovering ? .blue : Color.blue.opacity(0.8))
|
||||||
.underline()
|
.underline()
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.buttonStyle(buttonStyle)
|
.buttonStyle(buttonStyle)
|
||||||
.onHover { hovering in
|
.onHover { hovering in
|
||||||
self.hovering = hovering
|
self.hovering = hovering
|
||||||
}
|
}
|
||||||
|
|
||||||
if #available(macOS 10.16, iOS 14.0, *) {
|
return button.maybeHelp(url.absoluteString)
|
||||||
return AnyView(button.help(url.absoluteString))
|
}
|
||||||
|
|
||||||
|
private func maybeLinkImage(name: String) -> AnyView {
|
||||||
|
// can't use availability check inside view body, since buildLimitedAvailability was introduced in iOS 14 :/
|
||||||
|
if #available(iOS 13.0, macOS 11.0, *) {
|
||||||
|
return AnyView(Image(systemName: name).frame(minWidth: 23, alignment: .leading))
|
||||||
} else {
|
} else {
|
||||||
return AnyView(button)
|
return AnyView(EmptyView())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,10 +139,12 @@ struct RenderingBlockView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RenderingBlockView_Previews: PreviewProvider {
|
struct RenderingBlockView_Previews: PreviewProvider {
|
||||||
|
static let doc = Document(url: URL(string: "gemini://localhost/test.gmi")!, lines: [.text("Some Text"), .quote("A Quote")])
|
||||||
|
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
RenderingBlockView(block: .text("Some Text"))
|
RenderingBlockView(document: doc, block: .text("Some Text"))
|
||||||
RenderingBlockView(block: .quote("A Quote"))
|
RenderingBlockView(document: doc, block: .quote("A Quote"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
//
|
||||||
|
// View+Extensions.swift
|
||||||
|
// GeminiRenderer
|
||||||
|
//
|
||||||
|
// Created by Shadowfacts on 9/29/20.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
func maybeHelp(_ help: String) -> some View {
|
||||||
|
if #available(iOS 14.0, macOS 11.0, *) {
|
||||||
|
self.help(help)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue