Files
Pulse/Sources/PulseUI/Features/FileViewer/RichTextView/RichTextViewModel.swift
2024-07-14 14:31:18 -04:00

230 lines
7.7 KiB
Swift

// The MIT License (MIT)
//
// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean).
#if os(iOS) || os(macOS) || os(visionOS)
import SwiftUI
import Pulse
import Combine
final class RichTextViewModel: ObservableObject {
// Search
@Published var searchOptions: StringSearchOptions = .default
@Published private(set) var selectedMatchIndex: Int = 0
@Published private(set) var matches: [SearchMatch] = []
@Published var searchTerm: String = ""
// Configuration
@Published var isLinkDetectionEnabled = true
var isToolbarHidden = false
let contentType: NetworkLogger.ContentType?
let originalText: NSAttributedString
var onLinkTapped: ((URL) -> Bool)?
var isEmpty: Bool { originalText.length == 0 }
weak var textView: UXTextView? // Not proper MVVM
var textStorage: NSTextStorage { textView?.textStorage ?? NSTextStorage(string: "") }
private var isSearchingInBackground = false
private var isSearchNeeded = false
private let queue = DispatchQueue(label: "com.github.kean.pulse.search")
private let settings = UserSettings.shared
private var cancellables = [AnyCancellable]()
struct SearchMatch {
let range: NSRange
let originalForegroundColor: UXColor
let originalBackgroundColor: UXColor?
}
convenience init(string: NSAttributedString = NSAttributedString()) {
self.init(string: string, contentType: nil)
}
init(string: NSAttributedString, contentType: NetworkLogger.ContentType?) {
self.originalText = string
self.contentType = contentType
Publishers.CombineLatest($searchTerm, $searchOptions)
.dropFirst()
.receive(on: DispatchQueue.main) // Make sure self returns new values
.sink { [weak self] _, _ in
self?.setSearchNeeded()
}.store(in: &cancellables)
}
func prepare(_ context: SearchContext?) {
guard let context = context else { return }
// Not updated self.searchTerm because searchable doesn't like that
let matches = search(searchTerm: context.searchTerm.text, in: textStorage.string as NSString, options: context.searchTerm.options)
didUpdateMatches(matches, string: textStorage)
if context.matchIndex < matches.count {
DispatchQueue.main.async {
#if os(iOS) || os(visionOS)
self.textView?.layoutManager.allowsNonContiguousLayout = false // Remove this workaround
UIView.performWithoutAnimation {
self.updateMatchIndex(context.matchIndex)
}
#else
self.updateMatchIndex(context.matchIndex)
#endif
}
}
}
func display(_ text: NSAttributedString) {
matches.removeAll()
textStorage.setAttributedString(text)
searchTerm = ""
}
func performUpdates(_ closure: (NSTextStorage) -> Void) {
textStorage.beginEditing()
closure(textStorage)
textStorage.endEditing()
}
private func setSearchNeeded() {
isSearchNeeded = true
searchIfNeeded()
}
private func searchIfNeeded() {
guard isSearchNeeded && !isSearchingInBackground else { return }
isSearchingInBackground = true
isSearchNeeded = false
let string = textStorage
let (searchTerm, options) = (searchTerm, searchOptions)
queue.async {
let matches = search(searchTerm: searchTerm, in: string.string as NSString, options: options)
DispatchQueue.main.async {
self.didUpdateMatches(matches, string: string)
}
}
}
private func didUpdateMatches(_ newMatches: [NSRange], string: NSAttributedString) {
performUpdates { _ in
clearMatches()
if string.length != textStorage.length {
textStorage.setAttributedString(string)
}
matches = newMatches.filter {
textStorage.attributes(at: $0.location, effectiveRange: nil)[.isTechnical] == nil
}.map {
let color = textStorage.attribute(.foregroundColor, at: $0.location, effectiveRange: nil) as? UXColor
let backgroundColor = textStorage.attribute(.backgroundColor, at: $0.location, effectiveRange: nil) as? UXColor
return SearchMatch(range: $0, originalForegroundColor: color ?? .label, originalBackgroundColor: backgroundColor)
}
for match in matches {
highlight(range: match.range)
}
}
selectedMatchIndex = 0
didUpdateCurrentSelectedMatch()
isSearchingInBackground = false
searchIfNeeded()
}
func nextMatch() {
guard !matches.isEmpty else { return }
updateMatchIndex(selectedMatchIndex + 1 < matches.count ? selectedMatchIndex + 1 : 0)
}
func previousMatch() {
guard !matches.isEmpty else { return }
updateMatchIndex(selectedMatchIndex - 1 < 0 ? matches.count - 1 : selectedMatchIndex - 1)
}
func updateMatchIndex(_ newIndex: Int) {
let previousIndex = selectedMatchIndex
selectedMatchIndex = newIndex
didUpdateCurrentSelectedMatch(previousMatch: previousIndex)
}
private func didUpdateCurrentSelectedMatch(previousMatch: Int? = nil) {
guard !matches.isEmpty else { return }
// Scroll to visible range
// Make sure it's somewhere in the middle (find newlines)
var range = matches[selectedMatchIndex].range
var index = range.upperBound
var newlines = 0
let string = textStorage.string as NSString
while index < textStorage.length {
if let character = Character(string.character(at: index)), character.isNewline {
newlines += 1
range.length += index - range.upperBound
if newlines == 8 {
break
}
}
index += 1
}
if let textView = textView {
textView.scrollRangeToVisible(range)
}
// Update highlights
if let previousMatch = previousMatch {
highlight(range: matches[previousMatch].range)
}
highlight(range: matches[selectedMatchIndex].range, isFocused: true)
#if os(macOS)
// A workaround for macOS where it doesn't always redraw itself correctly
textView?.setNeedsDisplay(textView?.bounds ?? .zero)
#endif
}
private func clearMatches() {
for match in matches {
let range = match.range
#if os(macOS)
textStorage.addAttribute(.foregroundColor, value: match.originalForegroundColor, range: range)
textStorage.removeAttribute(.underlineStyle, range: range)
#else
textStorage.addAttribute(.foregroundColor, value: match.originalForegroundColor, range: range)
textStorage.removeAttribute(.backgroundColor, range: range)
if let backgroundColor = match.originalBackgroundColor {
textStorage.addAttribute(.backgroundColor, value: backgroundColor, range: range)
}
#endif
}
}
private func highlight(range: NSRange, isFocused: Bool = false) {
textStorage.addAttributes([
.backgroundColor: UXColor.systemBlue.withAlphaComponent(isFocused ? 0.8 : 0.3),
.foregroundColor: UXColor.white
], range: range)
}
}
private func search(searchTerm: String, in string: NSString, options: StringSearchOptions) -> [NSRange] {
guard searchTerm.count >= 1 else {
return []
}
return string.ranges(of: searchTerm, options: options)
}
#endif
extension RichTextViewModel {
struct SearchContext {
let searchTerm: ConsoleSearchTerm
let matchIndex: Int
}
}