Compare commits

...

43 Commits

Author SHA1 Message Date
Shadowfacts 7253b1218a Bump version and update changelog 2020-12-21 18:10:48 -05:00
Shadowfacts bb16c9ca9f Handle blank schemes when parrsing link URLs 2020-12-21 17:53:47 -05:00
Shadowfacts 7506ff3225 Fix Preferences Done button not working 2020-12-21 17:46:18 -05:00
Shadowfacts 5e2af7d678
Add web view fallback to display images 2020-12-21 11:13:51 -05:00
Shadowfacts a8a8ea10a1 Fix redirects not being handled
Closes #1
2020-12-20 22:57:29 -05:00
Shadowfacts d2f3ddf864 Fix reload not working 2020-12-20 22:27:59 -05:00
Shadowfacts 71831e58f2 Insert gemini protocol into host-only URLs 2020-12-20 15:32:36 -05:00
Shadowfacts e27ac15635 Fix keyboard not being dismissed in some circumstances 2020-12-20 15:25:52 -05:00
Shadowfacts 040a799b7b Fix not committing URL when pressing return 2020-12-20 15:24:36 -05:00
Shadowfacts 92fe14cd9b Add fallback for opening non-Gemini/HTTP URLs 2020-12-20 15:08:00 -05:00
Shadowfacts d4af73a18e Fix previewing HTTP(S) URLs 2020-12-20 15:07:37 -05:00
Shadowfacts a0a12f3865 Add context menu previews 2020-12-20 14:39:26 -05:00
Shadowfacts 444c4c053d Fix toolbar being too close to screen bottom on non-notched iPhones 2020-12-20 14:37:31 -05:00
Shadowfacts f647402a41 Prevent nav swipes from starting inside toolbars 2020-12-20 14:19:26 -05:00
Shadowfacts 43b105c85e Fix toolbars disappearing when tapping screen while scrolling 2020-12-20 14:11:02 -05:00
Shadowfacts f2b78d676f Fix wrong navigation styles on iPadOS 2020-12-20 14:09:18 -05:00
Shadowfacts d8b1c4d9c6 Remove old code 2020-12-20 14:07:23 -05:00
Shadowfacts a3af047591 Re-add back/forwards context menus 2020-12-20 14:03:38 -05:00
Shadowfacts be2e087a9e Fix forwards butto not being disabled when going to a new page 2020-12-20 13:47:49 -05:00
Shadowfacts 2b06a826ae Add Table of Contents 2020-12-20 13:45:22 -05:00
Shadowfacts 1454e9dc01 Fix incorrect selector name 2020-12-20 12:55:46 -05:00
Shadowfacts d3c196949e Re-add navigation bar 2020-12-19 22:43:43 -05:00
Shadowfacts 256cb0958e Fix scroll bar not appearing in dark mode 2020-12-19 19:40:31 -05:00
Shadowfacts 7c1a8aa2f5 Re-add toolbar 2020-12-19 15:21:46 -05:00
Shadowfacts abb80df9a7 Replace UINavigationController with custom navigation controller 2020-12-17 22:27:23 -05:00
Shadowfacts 314d8cf82c Add link decorations 2020-12-17 18:26:32 -05:00
Shadowfacts 2d60f733c3 Replace SwiftUI renderer with HTML/WKWebView 2020-12-16 23:09:09 -05:00
Shadowfacts 9d1d8828a0
Bump build number and update changelog 2020-09-30 21:31:50 -04:00
Shadowfacts 203bd1804f
iOS: Fix crash showing share sheet on iPad 2020-09-30 20:28:08 -04:00
Shadowfacts 364ffe9f94
iOS: Add pointer effects to various buttons 2020-09-30 18:14:38 -04:00
Shadowfacts 4f3e1432e7
iOS: Add accessibility labels to toolbar buttons 2020-09-30 18:10:36 -04:00
Shadowfacts 89b226e321
iOS: Improve link contrast in dark mode 2020-09-30 18:03:07 -04:00
Shadowfacts 107c4b0d72
macOS: Fix compilation 2020-09-30 18:02:22 -04:00
Shadowfacts 83dad76b82
iOS: Add theme override setting 2020-09-29 23:44:44 -04:00
Shadowfacts b000f1c2b3
iOS: Add back/forward history context menus 2020-09-29 23:36:24 -04:00
Shadowfacts 01a3eaf17f
Bump build number 2020-09-29 16:47:00 -04:00
Shadowfacts 8a895b70c8
Tweak link icons 2020-09-29 16:28:37 -04:00
Shadowfacts 19848ba8e4
Don't send requests with empty paths 2020-09-29 16:28:17 -04:00
Shadowfacts 182bb4b79b
Prevent potential redirect loops 2020-09-29 16:28:05 -04:00
Shadowfacts 71b6352395
Renderer: Add link icons 2020-09-29 15:30:10 -04:00
Shadowfacts 1449dc215b
iOS: Fix toolbars disappearing when scrolling too far up 2020-09-29 15:29:58 -04:00
Shadowfacts 012ada4af7
iOS: Actually save preferences 2020-09-29 15:29:12 -04:00
Shadowfacts 57023d204d
iOS: Go back to full SwiftUI
Embedding a UIHostingController inside a UIScrollView prevents
LazyVStack from working, causing very bad performance on some pages
2020-09-29 15:28:33 -04:00
34 changed files with 1907 additions and 517 deletions

View File

@ -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()

View File

@ -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)
}
}

37
CHANGELOG.md Normal file
View File

@ -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

View File

@ -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
}

View File

@ -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
}
}
}

View File

@ -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()
}
}
}

View File

@ -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)
}
}
}
}

View File

@ -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: {})
}
}

View File

@ -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")!))
}
}

View File

@ -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
}
}
}

View File

@ -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 {}

View File

@ -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: {})
}
}

View File

@ -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%);
}
}

View File

@ -9,12 +9,15 @@ import UIKit
import SwiftUI
import BrowserCore
import SafariServices
import Combine
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
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`.
@ -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>) {
@ -82,18 +93,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// Use this method to save data, release shared resources, and store enough scene-specific state information
// 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)
}
}
}

View File

@ -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()
}
}

View File

@ -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 }
}
}

View File

@ -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: {})
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 */;
}

View File

@ -59,7 +59,7 @@
<EnvironmentVariable
key = "DEFAULT_URL"
value = "gemini://drewdevault.com"
isEnabled = "YES">
isEnabled = "NO">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>

View File

@ -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>

View File

@ -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
}

View File

@ -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()
}
@objc private func forward() {
navigator.goForward()
}
}
extension NavigationManager: NSToolbarItemValidation {
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
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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))
}
}

View File

@ -88,11 +88,13 @@ 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) {
return 0
}
}
// 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) {

View File

@ -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])
}

View File

@ -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
}
}
}

View File

@ -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: {
Text(verbatim: text)
.font(.documentBody)
.foregroundColor(hovering ? .blue : Color.blue.opacity(0.8))
.underline()
.frame(maxWidth: .infinity, alignment: .leading)
HStack(alignment: .firstTextBaseline, spacing: 4) {
maybeLinkImage(name: imageName)
Text(verbatim: text)
.font(.documentBody)
.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"))
}
}
}

View File

@ -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
}
}
}