Files
Pulse/Sources/PulseUI/Helpers/TextRendererJSON.swift
T
2023-04-09 09:00:04 -04:00

302 lines
9.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The MIT License (MIT)
//
// Copyright (c) 20202023 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
#if os(macOS)
import AppKit
#endif
final class TextRendererJSON {
// Input
private let json: Any
private var error: NetworkLogger.DecodingError?
// Settings
private let options: TextRenderer.Options
private let helper: TextHelper
private let spaces = 2
// Temporary state (one-shot, doesn't reset)
private var indentation = 0
private var index = 0
private var codingPath: [NetworkLogger.DecodingError.CodingKey] = []
private var elements: [(NSRange, JSONElement, JSONContainerNode?)] = []
private var errorRange: NSRange?
private var string = ""
init(json: Any, error: NetworkLogger.DecodingError? = nil, options: TextRenderer.Options = .init()) {
self.options = options
self.helper = TextHelper()
self.json = json
self.error = error
}
func render() -> NSAttributedString {
render(json: json, isFree: true)
let output = NSMutableAttributedString(string: string, attributes: helper.attributes(role: .body2, style: .monospaced, color: color(for: .key)))
for (range, element, node) in elements {
output.addAttribute(.foregroundColor, value: color(for: element), range: range)
#if os(macOS)
if let node = node {
output.addAttribute(.node, value: node, range: range)
output.addAttribute(.cursor, value: NSCursor.pointingHand, range: range)
}
#endif
}
if let range = errorRange {
output.addAttributes(makeErrorAttributes(), range: range)
}
return output
}
private func color(for element: JSONElement) -> UXColor {
if options.color == .monochrome {
switch element {
case .punctuation: return UXColor.secondaryLabel
case .key: return UXColor.label
case .valueString: return UXColor.label
case .valueOther: return UXColor.label
case .null: return UXColor.label
}
} else {
switch element {
case .punctuation: return JSONColors.punctuation
case .key: return JSONColors.key
case .valueString: return JSONColors.valueString
case .valueOther: return JSONColors.valueOther
case .null: return JSONColors.null
}
}
}
// MARK: - Walk JSON
private func render(json: Any, isFree: Bool) {
switch json {
case let object as [String: Any]:
if isFree {
indent()
}
renderObject(object)
case let string as String:
renderString(string)
case let array as [Any]:
renderArray(array)
case let number as NSNumber:
renderNumber(number)
default:
if json is NSNull {
append("null", .null)
} else {
append("\(json)", .valueOther)
}
}
}
private func render(json: Any, key: NetworkLogger.DecodingError.CodingKey, isFree: Bool) {
codingPath.append(key)
render(json: json, isFree: isFree)
codingPath.removeLast()
}
private func renderObject(_ object: [String: Any]) {
let node = JSONContainerNode(kind: .object, json: json)
append("{", .punctuation, node)
newline()
let keys = object.keys.sorted()
for index in keys.indices {
let key = keys[index]
indent()
append(String(repeating: " ", count: spaces), .punctuation)
append("\"\(key)\"", .key)
append(": ", .punctuation)
indentation += 1
render(json: object[key]!, key: .string(key), isFree: false)
indentation -= 1
if index < keys.endIndex - 1 {
append(",", .punctuation)
}
newline()
}
indent()
append("}", .punctuation, node)
}
private func renderArray(_ array: [Any]) {
let node = JSONContainerNode(kind: .array, json: json)
if array is [String] || array is [Int] || array is [NSNumber] {
append("[", .punctuation, node)
for index in array.indices {
render(json: array[index], key: .int(index), isFree: true)
if index < array.endIndex - 1 {
append(", ", .punctuation)
}
}
append("]", .punctuation, node)
} else {
append("[", .punctuation, node)
append("\n", .punctuation)
indentation += 1
for index in array.indices {
render(json: array[index], key: .int(index), isFree: true)
if index < array.endIndex - 1 {
append(",", .punctuation)
}
newline()
}
indentation -= 1
indent()
append("]", .punctuation, node)
}
}
private func renderString(_ string: String) {
append("\"\(string)\"", .valueString)
}
private func renderNumber(_ number: NSNumber) {
if number === kCFBooleanTrue {
append("true", .valueOther)
} else if number === kCFBooleanFalse {
append("false", .valueOther)
} else {
append("\(number)", .valueOther)
}
}
// MARK: - Modify String
private var previousElement: JSONElement?
private func append(_ string: String, _ element: JSONElement, _ node: JSONContainerNode? = nil) {
let length = string.utf16.count
self.string += string
if element != .key { // Style for keys is the default one
if previousElement == element, element != .punctuation { // Coalesce the same elements
elements[elements.endIndex - 1].0.length += length
} else {
elements.append((NSRange(location: index, length: length), element, node))
}
}
previousElement = element
if let error = self.error, errorRange == nil, codingPath == error.context?.codingPath {
switch error {
case .keyNotFound:
// Display error on the first key in the object regardless of what it is
if element == .key {
errorRange = NSRange(location: index, length: length)
}
default:
errorRange = NSRange(location: index, length: length)
}
}
index += length
}
private func indent() {
append(String(repeating: " ", count: indentation * spaces), .punctuation)
}
private func newline() {
append("\n", .punctuation)
}
// MARK: Error
func makeErrorAttributes() -> [NSAttributedString.Key: Any] {
guard let error = error else {
return [:]
}
#if PULSE_STANDALONE_APP
return [
.decodingError: error,
.underlineColor: UXColor.red,
.underlineStyle: RichTextViewUnderlyingStyle.error.rawValue,
.cursor: NSCursor.pointingHand
]
#else
return [
.backgroundColor: options.color == .monochrome ? UXColor.label : UXColor.red,
.foregroundColor: UXColor.white,
.decodingError: error,
.link: {
var components = URLComponents()
components.scheme = "pulse"
components.path = "tooltip"
components.queryItems = [
URLQueryItem(name: "title", value: "Decoding Error"),
URLQueryItem(name: "message", value: error.debugDescription)
]
return components.url as Any
}(),
.underlineColor: UXColor.clear
]
#endif
}
}
struct JSONColors {
static let punctuation = UXColor.dynamic(
light: .init(red: 113.0/255.0, green: 128.0/255.0, blue: 141.0/255.0, alpha: 1.0),
dark: .init(red: 108.0/255.0, green: 121.0/255.0, blue: 134.0/255.0, alpha: 1.0)
)
static let key = UXColor.label
static let valueString = Palette.red
static let valueOther = UXColor.dynamic(
light: .init(red: 28.0/255.0, green: 0.0/255.0, blue: 207.0/255.0, alpha: 1.0),
dark: .init(red: 208.0/255.0, green: 191.0/255.0, blue: 105.0/255.0, alpha: 1.0)
)
static let null = Palette.pink
}
extension NSAttributedString.Key {
static let decodingError = NSAttributedString.Key(rawValue: "com.github.kean.pulse.decoding-error-key")
static let node = NSAttributedString.Key(rawValue: "com.github.kean.pulse.json-container-node")
}
enum JSONElement {
case punctuation
case key
case valueString
case valueOther
case null
}
final class JSONContainerNode {
enum Kind {
case object
case array
}
let kind: Kind
let json: Any
var isExpanded = true
var expanded: NSAttributedString?
init(kind: Kind, json: Any) {
self.kind = kind
self.json = json
}
var openingCharacter: String {
switch kind {
case .object: return "{"
case .array: return "["
}
}
var closingCharacter: String {
switch kind {
case .object: return "}"
case .array: return "]"
}
}
}