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")
|
||||
.font(.headline)
|
||||
Text(message)
|
||||
.lineLimit(nil)
|
||||
case let .document(doc):
|
||||
DocumentView(document: doc, scrollingEnabled: scrollingEnabled, changeURL: navigator.changeURL)
|
||||
Spacer()
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
public protocol NavigationManagerDelegate: class {
|
||||
func loadNonGeminiURL(_ url: URL)
|
||||
|
@ -19,6 +20,16 @@ public class NavigationManager: NSObject, ObservableObject {
|
|||
@Published public var backStack = [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) {
|
||||
self.currentURL = url
|
||||
}
|
||||
|
@ -30,9 +41,6 @@ public class NavigationManager: NSObject, ObservableObject {
|
|||
delegate?.loadNonGeminiURL(url)
|
||||
return
|
||||
}
|
||||
if components.port == 1965 {
|
||||
components.port = nil
|
||||
}
|
||||
} else {
|
||||
components.scheme = "gemini"
|
||||
}
|
||||
|
@ -43,28 +51,60 @@ public class NavigationManager: NSObject, ObservableObject {
|
|||
components.path = "/"
|
||||
}
|
||||
|
||||
// Some Gemini servers break on empty paths
|
||||
if components.path.isEmpty {
|
||||
components.path = "/"
|
||||
}
|
||||
|
||||
let url = components.url!
|
||||
|
||||
backStack.append(currentURL)
|
||||
currentURL = url
|
||||
forwardStack = []
|
||||
|
||||
navigationOperation.send(.go)
|
||||
}
|
||||
|
||||
public func reload() {
|
||||
@objc public func reload() {
|
||||
let url = currentURL
|
||||
currentURL = url
|
||||
navigationOperation.send(.reload)
|
||||
}
|
||||
|
||||
@objc public func back() {
|
||||
guard !backStack.isEmpty else { return }
|
||||
@objc public func goBack() {
|
||||
back(count: 1)
|
||||
}
|
||||
|
||||
public func back(count: Int) {
|
||||
guard count <= backStack.count else { return }
|
||||
var removed = backStack.suffix(count)
|
||||
backStack.removeLast(count)
|
||||
forwardStack.insert(currentURL, at: 0)
|
||||
currentURL = backStack.removeLast()
|
||||
currentURL = removed.removeFirst()
|
||||
forwardStack.insert(contentsOf: removed, at: 0)
|
||||
|
||||
navigationOperation.send(.backward(count: count))
|
||||
}
|
||||
|
||||
@objc public func forward() {
|
||||
guard !forwardStack.isEmpty else { return }
|
||||
@objc public func goForward() {
|
||||
forward(count: 1)
|
||||
}
|
||||
|
||||
public func forward(count: Int) {
|
||||
guard count <= forwardStack.count else { return }
|
||||
var removed = forwardStack.prefix(count)
|
||||
forwardStack.removeFirst(count)
|
||||
backStack.append(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 {
|
||||
// Override point for customization after application launch.
|
||||
SymbolCache.load()
|
||||
|
||||
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.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class Preferences: Codable, ObservableObject {
|
||||
|
||||
|
@ -34,6 +34,8 @@ class Preferences: Codable, ObservableObject {
|
|||
required init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
theme = try container.decode(UIUserInterfaceStyle.self, forKey: .theme)
|
||||
|
||||
useInAppSafari = try container.decode(Bool.self, forKey: .useInAppSafari)
|
||||
useReaderMode = try container.decode(Bool.self, forKey: .useReaderMode)
|
||||
}
|
||||
|
@ -41,16 +43,25 @@ class Preferences: Codable, ObservableObject {
|
|||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
|
||||
try container.encode(theme, forKey: .theme)
|
||||
|
||||
try container.encode(useInAppSafari, forKey: .useInAppSafari)
|
||||
try container.encode(useReaderMode, forKey: .useReaderMode)
|
||||
}
|
||||
|
||||
@Published var theme = UIUserInterfaceStyle.unspecified
|
||||
|
||||
@Published var useInAppSafari = false
|
||||
@Published var useReaderMode = false
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case theme
|
||||
|
||||
case useInAppSafari
|
||||
case useReaderMode
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
extension UIUserInterfaceStyle: Codable {}
|
||||
|
|
|
@ -8,27 +8,44 @@
|
|||
import SwiftUI
|
||||
|
||||
struct PreferencesView: View {
|
||||
@ObservedObject var preferences: Preferences = .shared
|
||||
let dismiss: () -> Void
|
||||
|
||||
@Binding var presented: Bool
|
||||
@ObservedObject var preferences: Preferences = .shared
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
appearanceSection
|
||||
|
||||
safariSection
|
||||
}
|
||||
.navigationBarTitle("Preferences")
|
||||
.insetOrGroupedListStyle()
|
||||
.navigationBarItems(trailing: doneButton)
|
||||
}
|
||||
.navigationViewStyle(StackNavigationViewStyle())
|
||||
.onDisappear {
|
||||
Preferences.save()
|
||||
}
|
||||
}
|
||||
|
||||
private var doneButton: some View {
|
||||
Button(action: {
|
||||
presented = false
|
||||
dismiss()
|
||||
}, label: {
|
||||
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 {
|
||||
|
@ -56,6 +73,6 @@ struct PreferencesView_Previews: PreviewProvider {
|
|||
@State static var presented = true
|
||||
|
||||
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 BrowserCore
|
||||
import SafariServices
|
||||
import Combine
|
||||
|
||||
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
|
||||
|
@ -16,6 +17,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
|
||||
var navigationManager: NavigationManager!
|
||||
|
||||
private var cancellables = [AnyCancellable]()
|
||||
|
||||
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`.
|
||||
// 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
|
||||
|
||||
// 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.
|
||||
if let windowScene = scene as? UIWindowScene {
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
window.overrideUserInterfaceStyle = Preferences.shared.theme
|
||||
window.rootViewController = BrowserNavigationController(navigator: navigationManager)
|
||||
// window.rootViewController = UIHostingController(rootView: contentView)
|
||||
window.rootViewController = BrowserViewController(navigator: navigationManager)
|
||||
// window.rootViewController = BrowserViewController(navigator: navigationManager)
|
||||
self.window = window
|
||||
window.makeKeyAndVisible()
|
||||
}
|
||||
|
||||
Preferences.shared.$theme
|
||||
.sink { (newStyle) in
|
||||
self.window!.overrideUserInterfaceStyle = newStyle
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
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.
|
||||
}
|
||||
|
||||
private func shareCurrentURL() {
|
||||
let vc = UIActivityViewController(activityItems: [navigationManager.currentURL], applicationActivities: nil)
|
||||
window?.rootViewController?.present(vc, animated: true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SceneDelegate: NavigationManagerDelegate {
|
||||
func loadNonGeminiURL(_ url: URL) {
|
||||
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 {
|
||||
let config = SFSafariViewController.Configuration()
|
||||
config.entersReaderIfAvailable = Preferences.shared.useReaderMode
|
||||
|
@ -102,6 +109,13 @@ extension SceneDelegate: NavigationManagerDelegate {
|
|||
} else {
|
||||
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;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 50;
|
||||
objectVersion = 52;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
|
@ -39,19 +39,29 @@
|
|||
D664673624BD07F700B0B741 /* RenderingBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673524BD07F700B0B741 /* RenderingBlock.swift */; };
|
||||
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673724BD086F00B0B741 /* RenderingBlockView.swift */; };
|
||||
D664673A24BD0B8E00B0B741 /* Fonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673924BD0B8E00B0B741 /* Fonts.swift */; };
|
||||
D688F586258AC738003A0A73 /* GeminiHTMLRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */; };
|
||||
D688F590258AC814003A0A73 /* HTMLEntities in Frameworks */ = {isa = PBXBuildFile; productRef = D688F58F258AC814003A0A73 /* HTMLEntities */; };
|
||||
D688F599258ACAAE003A0A73 /* BrowserWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */; };
|
||||
D688F5FF258ACE6B003A0A73 /* browser.css in Resources */ = {isa = PBXBuildFile; fileRef = D688F5FE258ACE6B003A0A73 /* browser.css */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
D6E1529924BFAAA400FDF9D3 /* BrowserWindowController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E1529724BFAAA400FDF9D3 /* BrowserWindowController.xib */; };
|
||||
D6E1529B24BFAEC700FDF9D3 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6E1529A24BFAEC700FDF9D3 /* MainMenu.xib */; };
|
||||
D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6E152A424BFFDF500FDF9D3 /* AppDelegate.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 */; };
|
||||
D6E152AE24BFFDF600FDF9D3 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D6E152AD24BFFDF600FDF9D3 /* Preview Assets.xcassets */; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
|
@ -370,6 +389,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D688F590258AC814003A0A73 /* HTMLEntities in Frameworks */,
|
||||
D62664F024BC0D7700DF9B88 /* GeminiFormat.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -502,6 +522,7 @@
|
|||
D62664AB24BBF26A00DF9B88 /* Info.plist */,
|
||||
D62664C724BBF2C600DF9B88 /* Document.swift */,
|
||||
D62664C524BBF27300DF9B88 /* GeminiParser.swift */,
|
||||
D6BC9AC4258F01F6008652BC /* TableOfContents.swift */,
|
||||
);
|
||||
path = GeminiFormat;
|
||||
sourceTree = "<group>";
|
||||
|
@ -511,6 +532,7 @@
|
|||
children = (
|
||||
D62664F924BC12BC00DF9B88 /* DocumentTests.swift */,
|
||||
D62664B724BBF26A00DF9B88 /* GeminiParserTests.swift */,
|
||||
D6BC9ACD258F07BC008652BC /* TableOfContentsTests.swift */,
|
||||
D62664B924BBF26A00DF9B88 /* Info.plist */,
|
||||
);
|
||||
path = GeminiFormatTests;
|
||||
|
@ -526,6 +548,8 @@
|
|||
D62664ED24BC0BCE00DF9B88 /* MaybeLazyVStack.swift */,
|
||||
D62664EB24BC0B4D00DF9B88 /* DocumentView.swift */,
|
||||
D664673724BD086F00B0B741 /* RenderingBlockView.swift */,
|
||||
D6DA5782252396030048B65A /* View+Extensions.swift */,
|
||||
D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */,
|
||||
);
|
||||
path = GeminiRenderer;
|
||||
sourceTree = "<group>";
|
||||
|
@ -546,17 +570,30 @@
|
|||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D688F618258AD231003A0A73 /* Resources */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D688F5FE258ACE6B003A0A73 /* browser.css */,
|
||||
);
|
||||
path = Resources;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D6E152A324BFFDF500FDF9D3 /* Gemini-iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D6E152A424BFFDF500FDF9D3 /* AppDelegate.swift */,
|
||||
D6E152A624BFFDF500FDF9D3 /* SceneDelegate.swift */,
|
||||
D691A6762522382E00348C4B /* BrowserViewController.swift */,
|
||||
D6E152A824BFFDF500FDF9D3 /* ContentView.swift */,
|
||||
D691A68625223A4600348C4B /* NavigationBar.swift */,
|
||||
D691A69F252242FC00348C4B /* ToolBar.swift */,
|
||||
D688F649258C17F3003A0A73 /* SymbolCache.swift */,
|
||||
D688F659258C2256003A0A73 /* BrowserNavigationController.swift */,
|
||||
D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */,
|
||||
D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */,
|
||||
D688F662258C2479003A0A73 /* UIViewController+Children.swift */,
|
||||
D6BC9AB2258E8E13008652BC /* ToolbarView.swift */,
|
||||
D6BC9ABB258E9862008652BC /* NavigationBarView.swift */,
|
||||
D6BC9AD6258FC8B3008652BC /* TableOfContentsView.swift */,
|
||||
D691A64D25217C6F00348C4B /* Preferences.swift */,
|
||||
D691A66625217FD800348C4B /* PreferencesView.swift */,
|
||||
D688F618258AD231003A0A73 /* Resources */,
|
||||
D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */,
|
||||
D6E152AF24BFFDF600FDF9D3 /* LaunchScreen.storyboard */,
|
||||
D6E152B224BFFDF600FDF9D3 /* Info.plist */,
|
||||
|
@ -743,6 +780,9 @@
|
|||
D68544302522E10F004C4AE0 /* PBXTargetDependency */,
|
||||
);
|
||||
name = GeminiRenderer;
|
||||
packageProductDependencies = (
|
||||
D688F58F258AC814003A0A73 /* HTMLEntities */,
|
||||
);
|
||||
productName = GeminiRenderer;
|
||||
productReference = D62664CE24BC081B00DF9B88 /* GeminiRenderer.framework */;
|
||||
productType = "com.apple.product-type.framework";
|
||||
|
@ -885,6 +925,9 @@
|
|||
Base,
|
||||
);
|
||||
mainGroup = D626645224BBF1C200DF9B88;
|
||||
packageReferences = (
|
||||
D688F58E258AC814003A0A73 /* XCRemoteSwiftPackageReference "swift-html-entities" */,
|
||||
);
|
||||
productRefGroup = D626645C24BBF1C200DF9B88 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
|
@ -963,6 +1006,7 @@
|
|||
files = (
|
||||
D6E152B124BFFDF600FDF9D3 /* LaunchScreen.storyboard in Resources */,
|
||||
D6E152AE24BFFDF600FDF9D3 /* Preview Assets.xcassets in Resources */,
|
||||
D688F5FF258ACE6B003A0A73 /* browser.css in Resources */,
|
||||
D6E152AB24BFFDF600FDF9D3 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -1022,6 +1066,7 @@
|
|||
files = (
|
||||
D62664C824BBF2C600DF9B88 /* Document.swift in Sources */,
|
||||
D62664C624BBF27300DF9B88 /* GeminiParser.swift in Sources */,
|
||||
D6BC9AC5258F01F6008652BC /* TableOfContents.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -1030,6 +1075,7 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D62664FA24BC12BC00DF9B88 /* DocumentTests.swift in Sources */,
|
||||
D6BC9ACE258F07BC008652BC /* TableOfContentsTests.swift in Sources */,
|
||||
D62664B824BBF26A00DF9B88 /* GeminiParserTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -1042,6 +1088,8 @@
|
|||
D664673A24BD0B8E00B0B741 /* Fonts.swift in Sources */,
|
||||
D62664EE24BC0BCE00DF9B88 /* MaybeLazyVStack.swift in Sources */,
|
||||
D62664EC24BC0B4D00DF9B88 /* DocumentView.swift in Sources */,
|
||||
D6DA5783252396030048B65A /* View+Extensions.swift in Sources */,
|
||||
D688F586258AC738003A0A73 /* GeminiHTMLRenderer.swift in Sources */,
|
||||
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -1059,13 +1107,17 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
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 */,
|
||||
D691A6A0252242FC00348C4B /* ToolBar.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 */,
|
||||
D6E152A924BFFDF500FDF9D3 /* ContentView.swift in Sources */,
|
||||
D6BC9ABC258E9862008652BC /* NavigationBarView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -1325,7 +1377,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Gemini/Gemini.entitlements;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Gemini/Preview Content\"";
|
||||
|
@ -1351,7 +1403,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = Gemini/Gemini.entitlements;
|
||||
CODE_SIGN_IDENTITY = "-";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Gemini/Preview Content\"";
|
||||
|
@ -1692,7 +1744,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Gemini-iOS/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
@ -1702,7 +1754,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2020.1;
|
||||
MARKETING_VERSION = 2021.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Gemini;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -1718,7 +1770,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"Gemini-iOS/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = V4WK9KR9U2;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
|
@ -1728,7 +1780,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2020.1;
|
||||
MARKETING_VERSION = 2021.1;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = space.vaccor.Gemini;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -1943,6 +1995,25 @@
|
|||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
D688F58E258AC814003A0A73 /* XCRemoteSwiftPackageReference "swift-html-entities" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Kitura/swift-html-entities";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 3.0.200;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
D688F58F258AC814003A0A73 /* HTMLEntities */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = D688F58E258AC814003A0A73 /* XCRemoteSwiftPackageReference "swift-html-entities" */;
|
||||
productName = HTMLEntities;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = D626645324BBF1C200DF9B88 /* Project object */;
|
||||
}
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
<EnvironmentVariable
|
||||
key = "DEFAULT_URL"
|
||||
value = "gemini://drewdevault.com"
|
||||
isEnabled = "YES">
|
||||
isEnabled = "NO">
|
||||
</EnvironmentVariable>
|
||||
</EnvironmentVariables>
|
||||
</LaunchAction>
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<key>BrowserCore.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>5</integer>
|
||||
<integer>3</integer>
|
||||
</dict>
|
||||
<key>Gemini-iOS.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
|
@ -27,12 +27,12 @@
|
|||
<key>GeminiProtocol.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>3</integer>
|
||||
<integer>4</integer>
|
||||
</dict>
|
||||
<key>GeminiRenderer.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>4</integer>
|
||||
<integer>5</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<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.paletteLabel = "Go Back"
|
||||
item.toolTip = "Go to the previous page"
|
||||
item.target = navigator
|
||||
item.action = #selector(NavigationManager.back)
|
||||
item.target = self
|
||||
item.action = #selector(back)
|
||||
item.isBordered = true
|
||||
if #available(macOS 10.16, *) {
|
||||
item.isNavigational = true
|
||||
|
@ -127,8 +127,8 @@ extension BrowserWindowController: NSToolbarDelegate {
|
|||
item.label = "Go Forward"
|
||||
item.paletteLabel = "Go Forward"
|
||||
item.toolTip = "Go to the next page"
|
||||
item.target = navigator
|
||||
item.action = #selector(NavigationManager.forward)
|
||||
item.target = self
|
||||
item.action = #selector(forward)
|
||||
item.isBordered = true
|
||||
if #available(macOS 10.16, *) {
|
||||
item.isNavigational = true
|
||||
|
@ -136,14 +136,22 @@ extension BrowserWindowController: NSToolbarDelegate {
|
|||
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 {
|
||||
if item.itemIdentifier == .goBack {
|
||||
return !backStack.isEmpty
|
||||
return !navigator.backStack.isEmpty
|
||||
} else if item.itemIdentifier == .goForward {
|
||||
return !forwardStack.isEmpty
|
||||
return !navigator.forwardStack.isEmpty
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
|
|
|
@ -21,6 +21,13 @@ public struct Document {
|
|||
// todo: should this be \r\n
|
||||
return lines.map { $0.geminiText() }.joined(separator: "\n")
|
||||
}
|
||||
|
||||
public var title: String? {
|
||||
for case let .heading(text, level: _) in lines {
|
||||
return text
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public extension Document {
|
||||
|
@ -63,7 +70,7 @@ public extension Document {
|
|||
}
|
||||
|
||||
public extension Document {
|
||||
enum HeadingLevel: Int {
|
||||
enum HeadingLevel: Int, Comparable {
|
||||
case h1 = 1, h2 = 2, h3 = 3
|
||||
|
||||
var geminiText: String {
|
||||
|
@ -76,5 +83,9 @@ public extension Document {
|
|||
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 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?
|
||||
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 message = NWProtocolFramer.Message(geminiResponseHeader: header)
|
||||
while true {
|
||||
if !framer.deliverInputNoCopy(length: .max, message: message, isComplete: true) {
|
||||
// What does the return value of deliverInputNoCopy mean, you ask? Why, I have no idea
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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") }
|
||||
|
|
|
@ -35,7 +35,7 @@ public struct DocumentView: View {
|
|||
private var scrollBody: some View {
|
||||
MaybeLazyVStack(alignment: .leading) {
|
||||
ForEach(blocks.indices) { (index) in
|
||||
RenderingBlockView(block: blocks[index], changeURL: changeURL)
|
||||
RenderingBlockView(document: document, block: blocks[index], changeURL: changeURL)
|
||||
}
|
||||
}.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
|
||||
|
||||
struct RenderingBlockView: View {
|
||||
let document: Document
|
||||
let block: RenderingBlock
|
||||
let changeURL: ((URL) -> Void)?
|
||||
@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.changeURL = changeURL
|
||||
}
|
||||
|
@ -37,7 +41,7 @@ struct RenderingBlockView: View {
|
|||
}
|
||||
|
||||
private func text(_ text: String) -> some View {
|
||||
Text(text)
|
||||
Text(verbatim: text)
|
||||
.font(.documentBody)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
@ -51,24 +55,50 @@ struct RenderingBlockView: View {
|
|||
let buttonStyle = PlainButtonStyle()
|
||||
#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 {
|
||||
self.changeURL?(url)
|
||||
} label: {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 4) {
|
||||
maybeLinkImage(name: imageName)
|
||||
|
||||
Text(verbatim: text)
|
||||
.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()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
.buttonStyle(buttonStyle)
|
||||
.onHover { hovering in
|
||||
self.hovering = hovering
|
||||
}
|
||||
|
||||
if #available(macOS 10.16, iOS 14.0, *) {
|
||||
return AnyView(button.help(url.absoluteString))
|
||||
return button.maybeHelp(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 {
|
||||
return AnyView(button)
|
||||
return AnyView(EmptyView())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -109,10 +139,12 @@ struct RenderingBlockView: View {
|
|||
}
|
||||
|
||||
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 {
|
||||
Group {
|
||||
RenderingBlockView(block: .text("Some Text"))
|
||||
RenderingBlockView(block: .quote("A Quote"))
|
||||
RenderingBlockView(document: doc, block: .text("Some Text"))
|
||||
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