mirror of
https://github.com/kean/Pulse.git
synced 2026-05-30 21:07:33 +00:00
302 lines
9.3 KiB
Swift
302 lines
9.3 KiB
Swift
// The MIT License (MIT)
|
||
//
|
||
// Copyright (c) 2020–2023 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 "]"
|
||
}
|
||
}
|
||
}
|