mirror of
https://github.com/kean/Pulse.git
synced 2026-05-30 21:07:33 +00:00
6e8de88911
`prepare()` was setting `layoutManager.allowsNonContiguousLayout = false` before calling `scrollRangeToVisible`. With it off, TextKit has to build layout contiguously from the start of the document to the target range, so jumping into a match deep inside a large response body (multi-MB JSON) stalls noticeably. The code already carried a `// Remove this workaround` comment, but I don't know the original reason it was added — so this is a best-effort cleanup. On large responses scrolling into a match feels visibly faster with no glitches observed (jumping to the first / middle / last match, opening with a pre-filled search term via TextViewSearchContext). If there's a specific scenario where the workaround was needed, happy to restore it or scope it differently.
257 lines
8.8 KiB
Swift
257 lines
8.8 KiB
Swift
// The MIT License (MIT)
|
|
//
|
|
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
|
|
|
import SwiftUI
|
|
import Pulse
|
|
import Combine
|
|
|
|
#if os(iOS) || os(visionOS)
|
|
|
|
public 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 public var isLinkDetectionEnabled = true
|
|
var isToolbarHidden = false
|
|
|
|
public let contentType: NetworkLogger.ContentType?
|
|
public let originalText: NSAttributedString
|
|
|
|
public var onLinkTapped: ((URL) -> Bool)?
|
|
|
|
public 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?
|
|
}
|
|
|
|
public convenience init(string: NSAttributedString = NSAttributedString()) {
|
|
self.init(string: string, contentType: nil)
|
|
}
|
|
|
|
public init(string: NSAttributedString, contentType: NetworkLogger.ContentType?) {
|
|
self.originalText = string
|
|
self.contentType = contentType
|
|
|
|
Publishers.CombineLatest($searchTerm, $searchOptions)
|
|
.dropFirst()
|
|
.debounce(for: .milliseconds(200), scheduler: DispatchQueue.main)
|
|
.sink { [weak self] _, _ in
|
|
self?.setSearchNeeded()
|
|
}.store(in: &cancellables)
|
|
}
|
|
|
|
func prepare(_ context: TextViewSearchContext?) {
|
|
guard let context = context else { return }
|
|
|
|
// Not updated self.searchTerm because searchable doesn't like that
|
|
let matches = search(searchTerm: context.searchTerm.text, in: originalText, options: context.searchTerm.options)
|
|
didUpdateMatches(matches, string: textStorage)
|
|
if context.matchIndex < matches.count {
|
|
DispatchQueue.main.async {
|
|
UIView.performWithoutAnimation {
|
|
self.updateMatchIndex(context.matchIndex)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
let originalText = self.originalText
|
|
|
|
queue.async {
|
|
let matches = search(searchTerm: searchTerm, in: originalText, options: options)
|
|
DispatchQueue.main.async {
|
|
self.didUpdateMatches(matches, string: string)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func didUpdateMatches(_ newMatches: [SearchMatch], string: NSAttributedString) {
|
|
performUpdates { _ in
|
|
clearMatches()
|
|
|
|
if string.length != textStorage.length {
|
|
textStorage.setAttributedString(string)
|
|
}
|
|
|
|
matches = newMatches
|
|
|
|
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 a slightly extended range so the match isn't pinned to the
|
|
// top edge. Use native newline search instead of a per-character loop.
|
|
var range = matches[selectedMatchIndex].range
|
|
let string = textStorage.string as NSString
|
|
var searchStart = range.upperBound
|
|
for _ in 0..<8 {
|
|
guard searchStart < string.length else { break }
|
|
let found = string.range(of: "\n", options: [], range: NSRange(location: searchStart, length: string.length - searchStart))
|
|
if found.location == NSNotFound { break }
|
|
range.length = found.location - range.location
|
|
searchStart = found.upperBound
|
|
}
|
|
textView?.scrollRangeToVisible(range)
|
|
|
|
// Update highlights
|
|
if let previousMatch = previousMatch {
|
|
highlight(range: matches[previousMatch].range)
|
|
}
|
|
highlight(range: matches[selectedMatchIndex].range, isFocused: true)
|
|
}
|
|
|
|
private func clearMatches() {
|
|
for match in matches {
|
|
let range = match.range
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func highlight(range: NSRange, isFocused: Bool = false) {
|
|
textStorage.addAttributes([
|
|
.backgroundColor: UXColor.systemBlue.withAlphaComponent(isFocused ? 0.8 : 0.3),
|
|
.foregroundColor: UXColor.white
|
|
], range: range)
|
|
}
|
|
}
|
|
|
|
/// Runs on a background queue. We resolve match metadata (technical-attribute filter
|
|
/// and the original colors used by `clearMatches`) from the immutable `originalText`
|
|
/// here, so the main-thread work in `didUpdateMatches` is just applying highlights.
|
|
private func search(searchTerm: String, in originalText: NSAttributedString, options: StringSearchOptions) -> [RichTextViewModel.SearchMatch] {
|
|
guard searchTerm.count >= 1 else {
|
|
return []
|
|
}
|
|
let ranges = (originalText.string as NSString).ranges(of: searchTerm, options: options)
|
|
return ranges.compactMap { range -> RichTextViewModel.SearchMatch? in
|
|
guard range.location < originalText.length else { return nil }
|
|
let attributes = originalText.attributes(at: range.location, effectiveRange: nil)
|
|
guard attributes[.isTechnical] == nil else { return nil }
|
|
let foreground = attributes[.foregroundColor] as? UXColor
|
|
let background = attributes[.backgroundColor] as? UXColor
|
|
return RichTextViewModel.SearchMatch(
|
|
range: range,
|
|
originalForegroundColor: foreground ?? .label,
|
|
originalBackgroundColor: background
|
|
)
|
|
}
|
|
}
|
|
|
|
#endif
|
|
|
|
private struct TextViewSearchContextKey: EnvironmentKey {
|
|
static var defaultValue: TextViewSearchContext?
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
package var textViewSearchContext: TextViewSearchContext? {
|
|
get { self[TextViewSearchContextKey.self] }
|
|
set { self[TextViewSearchContextKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
package struct TextViewSearchContext {
|
|
package let searchTerm: ConsoleSearchTerm
|
|
package let matchIndex: Int
|
|
|
|
package init(searchTerm: ConsoleSearchTerm, matchIndex: Int) {
|
|
self.searchTerm = searchTerm
|
|
self.matchIndex = matchIndex
|
|
}
|
|
}
|
|
|
|
#if os(watchOS) || os(tvOS) || os(macOS)
|
|
public final class RichTextViewModel: ObservableObject {
|
|
public let text: String
|
|
public let attributedString: AttributedString?
|
|
|
|
public var isLinkDetectionEnabled = true
|
|
public var isEmpty: Bool { text.isEmpty }
|
|
|
|
public init(string: String) {
|
|
self.text = string
|
|
self.attributedString = nil
|
|
}
|
|
|
|
public init(string: NSAttributedString, contentType: NetworkLogger.ContentType? = nil) {
|
|
#if os(macOS)
|
|
self.attributedString = try? AttributedString(string, including: \.appKit)
|
|
#else
|
|
self.attributedString = try? AttributedString(string, including: \.uiKit)
|
|
#endif
|
|
self.text = string.string
|
|
}
|
|
}
|
|
#endif
|