├── qr-code.jpg ├── RenderDemo ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── RenderDemo.entitlements ├── AppDelegate.swift └── Base.lproj │ └── MainMenu.xib ├── PreviewJson.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── WorkspaceSettings.xcsettings └── xcshareddata │ └── xcschemes │ ├── PreviewJsonTests.xcscheme │ ├── PreviewJsonUITests.xcscheme │ ├── RenderDemo.xcscheme │ └── PreviewJson.xcscheme ├── PreviewJson ├── PMFont.swift ├── PreviewJson.entitlements ├── Info.plist ├── GenericColorExtensions.swift ├── Constants.swift ├── GenericFontExtensions.swift ├── GenericExtensions.swift └── AppDelegate.swift ├── JSON Previewer ├── JSON_Previewer.entitlements ├── Info.plist ├── PreviewProvider.swift ├── Base.lproj │ └── PreviewViewController.xib ├── PreviewViewController.swift └── Common.swift ├── JSON Thumbnailer ├── JSON_Thumbnailer.entitlements ├── Info.plist └── ThumbnailProvider.swift ├── LICENSE.md ├── PreviewJsonTests └── PreviewJsonTests.swift ├── .gitignore └── README.md /qr-code.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/smittytone/PreviewJson/HEAD/qr-code.jpg -------------------------------------------------------------------------------- /RenderDemo/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /PreviewJson.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /RenderDemo/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /PreviewJson.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /PreviewJson.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /PreviewJson/PMFont.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * PMFont.swift 3 | * PreviewApps 4 | * 5 | * Created by Tony Smith on 02/07/2021. 6 | * Copyright © 2025 Tony Smith. All rights reserved. 7 | */ 8 | 9 | 10 | import Foundation 11 | 12 | /** 13 | Internal font record structure. 14 | */ 15 | 16 | struct PMFont { 17 | 18 | var postScriptName: String = "" 19 | var displayName: String = "" 20 | var styleName: String = "" 21 | var traits: UInt = 0 22 | var styles: [PMFont]? = nil 23 | } 24 | -------------------------------------------------------------------------------- /RenderDemo/RenderDemo.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | $(TeamIdentifierPrefix)suite.preview-previewjson 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /JSON Previewer/JSON_Previewer.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | $(TeamIdentifierPrefix)suite.preview-previewjson 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /JSON Thumbnailer/JSON_Thumbnailer.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | $(TeamIdentifierPrefix)suite.preview-previewjson 10 | 11 | com.apple.security.files.user-selected.read-only 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /PreviewJson/PreviewJson.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.application-groups 8 | 9 | $(TeamIdentifierPrefix)suite.preview-previewjson 10 | 11 | com.apple.security.automation.apple-events 12 | 13 | com.apple.security.files.user-selected.read-only 14 | 15 | com.apple.security.network.client 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /JSON Thumbnailer/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionAttributes 8 | 9 | QLSupportedContentTypes 10 | 11 | public.json 12 | 13 | QLThumbnailMinimumDimension 14 | 0 15 | 16 | NSExtensionPointIdentifier 17 | com.apple.quicklook.thumbnail 18 | NSExtensionPrincipalClass 19 | $(PRODUCT_MODULE_NAME).ThumbnailProvider 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /JSON Previewer/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSExtension 6 | 7 | NSExtensionAttributes 8 | 9 | QLIsDataBasedPreview 10 | 11 | QLSupportedContentTypes 12 | 13 | public.json 14 | 15 | QLSupportsSearchableItems 16 | 17 | 18 | NSExtensionMainNibFile 19 | PreviewViewController 20 | NSExtensionPointIdentifier 21 | com.apple.quicklook.preview 22 | NSExtensionPrincipalClass 23 | $(PRODUCT_MODULE_NAME).PreviewViewController 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | ### Copyright © 2024, Tony Smith (@smittytone) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /RenderDemo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /PreviewJsonTests/PreviewJsonTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewJsonTests.swift 3 | // PreviewJsonTests 4 | // 5 | // Created by Tony Smith on 29/08/2023. 6 | // 7 | 8 | import XCTest 9 | @testable import PreviewJson 10 | 11 | class PreviewJsonTests: XCTestCase { 12 | 13 | override func setUpWithError() throws { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | } 16 | 17 | override func tearDownWithError() throws { 18 | // Put teardown code here. This method is called after the invocation of each test method in the class. 19 | } 20 | 21 | func testExample() throws { 22 | // This is an example of a functional test case. 23 | // Use XCTAssert and related functions to verify your tests produce the correct results. 24 | // Any test you write for XCTest can be annotated as throws and async. 25 | // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. 26 | // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. 27 | } 28 | 29 | func testPerformanceExample() throws { 30 | // This is an example of a performance test case. 31 | self.measure { 32 | // Put the code you want to measure the time of here. 33 | } 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /PreviewJson/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | ITSAppUsesNonExemptEncryption 24 | 25 | LSApplicationCategoryType 26 | public.app-category.utilities 27 | LSMinimumSystemVersion 28 | $(MACOSX_DEPLOYMENT_TARGET) 29 | NSHumanReadableCopyright 30 | Copyright © 2025 Tony Smith. All rights reserved. 31 | NSMainNibFile 32 | MainMenu 33 | NSPrincipalClass 34 | NSApplication 35 | 36 | 37 | -------------------------------------------------------------------------------- /JSON Previewer/PreviewProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PreviewProvider.swift 3 | // JSON Previewer 4 | // 5 | // Created by Tony Smith on 29/08/2022. 6 | // 7 | 8 | import Cocoa 9 | import Quartz 10 | 11 | class PreviewProvider: QLPreviewProvider, QLPreviewingController { 12 | 13 | /* 14 | Use a QLPreviewProvider to provide data-based previews. 15 | 16 | To set up your extension as a data-based preview extension: 17 | 18 | - Modify the extension's Info.plist by setting 19 | QLIsDataBasedPreview 20 | 21 | 22 | - Add the supported content types to QLSupportedContentTypes array in the extension's Info.plist. 23 | 24 | - Change the NSExtensionPrincipalClass to this class. 25 | e.g. 26 | NSExtensionPrincipalClass 27 | $(PRODUCT_MODULE_NAME).PreviewProvider 28 | 29 | - Implement providePreview(for:) 30 | */ 31 | 32 | func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply { 33 | 34 | //You can create a QLPreviewReply in several ways, depending on the format of the data you want to return. 35 | //To return Data of a supported content type: 36 | 37 | let contentType = UTType.plainText // replace with your data type 38 | 39 | let reply = QLPreviewReply.init(dataOfContentType: contentType, contentSize: CGSize.init(width: 800, height: 800)) { (replyToUpdate : QLPreviewReply) in 40 | 41 | let data = Data("Hello world".utf8) 42 | 43 | //setting the stringEncoding for text and html data is optional and defaults to String.Encoding.utf8 44 | replyToUpdate.stringEncoding = .utf8 45 | 46 | //initialize your data here 47 | 48 | return data 49 | } 50 | 51 | return reply 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /PreviewJson.xcodeproj/xcshareddata/xcschemes/PreviewJsonTests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 37 | 38 | 44 | 45 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /PreviewJson.xcodeproj/xcshareddata/xcschemes/PreviewJsonUITests.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 14 | 15 | 17 | 23 | 24 | 25 | 26 | 27 | 37 | 38 | 44 | 45 | 47 | 48 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /PreviewJson/GenericColorExtensions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * GenericColorExtension.swift 3 | * PreviewApps 4 | * 5 | * Created by Tony Smith on 18/06/2021. 6 | * Copyright © 2025 Tony Smith. All rights reserved. 7 | */ 8 | 9 | 10 | import Foundation 11 | import Cocoa 12 | 13 | 14 | extension NSColor { 15 | 16 | /** 17 | Convert a colour's internal representation into an RGB+A hex string. 18 | */ 19 | var hexString: String { 20 | 21 | guard let rgbColour = usingColorSpace(.sRGB) else { 22 | return BUFFOON_CONSTANTS.KEY_COLOUR_HEX 23 | } 24 | 25 | let red: Int = Int(round(rgbColour.redComponent * 0xFF)) 26 | let green: Int = Int(round(rgbColour.greenComponent * 0xFF)) 27 | let blue: Int = Int(round(rgbColour.blueComponent * 0xFF)) 28 | let alpha: Int = Int(round(rgbColour.alphaComponent * 0xFF)) 29 | 30 | let hexString: NSString = NSString(format: "%02X%02X%02X%02X", red, green, blue, alpha) 31 | return hexString as String 32 | } 33 | 34 | 35 | /** 36 | Generate a new NSColor from an RGB+A hex string.. 37 | 38 | - Parameters: 39 | - hex: The RGB+A hex string, eg.`AABBCCFF`. 40 | 41 | - Returns: An NSColor instance. 42 | */ 43 | static func hexToColour(_ hex: String) -> NSColor { 44 | 45 | if hex.count != 8 { 46 | return NSColor.red 47 | } 48 | 49 | func hexToFloat(_ hs: String) -> CGFloat { 50 | return CGFloat(UInt8(hs, radix: 16) ?? 0) 51 | } 52 | 53 | let hexns: NSString = hex as NSString 54 | let red: CGFloat = hexToFloat(hexns.substring(with: NSRange.init(location: 0, length: 2))) / 255 55 | let green: CGFloat = hexToFloat(hexns.substring(with: NSRange.init(location: 2, length: 2))) / 255 56 | let blue: CGFloat = hexToFloat(hexns.substring(with: NSRange.init(location: 4, length: 2))) / 255 57 | let alpha: CGFloat = hexToFloat(hexns.substring(with: NSRange.init(location: 6, length: 2))) / 255 58 | return NSColor.init(srgbRed: red, green: green, blue: blue, alpha: alpha) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | -------------------------------------------------------------------------------- /PreviewJson.xcodeproj/xcshareddata/xcschemes/RenderDemo.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /PreviewJson.xcodeproj/xcshareddata/xcschemes/PreviewJson.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 11 | 14 | 15 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 33 | 39 | 40 | 41 | 42 | 43 | 48 | 49 | 50 | 51 | 61 | 63 | 69 | 70 | 71 | 72 | 78 | 80 | 86 | 87 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PreviewJson 1.1.4 # 2 | 3 | QuickLook JSON preview and icon thumbnailing app extensions for macOS Catalina and beyond 4 | 5 | ![PreviewJson App Store QR code](qr-code.jpg) 6 | 7 | ## Installation and Usage ## 8 | 9 | Just run the host app once to register the extensions — you can quit the app as soon as it has launched. We recommend logging out of your Mac and back in again at this point. Now you can preview markdown documents using QuickLook (select an icon and hit Space), and Finder’s preview pane and **Info** panels. 10 | 11 | You can disable and re-enable the Previewer and Thumbnailer extensions at any time in **System Preferences > Extensions > Quick Look**. 12 | 13 | ### Adjusting the Preview ### 14 | 15 | You can alter some of the key elements of the preview by using the **Preferences** panel: 16 | 17 | - The colour of object keys, strings, `true`/`false`/`null` when displayed as text, and JSON tags. 18 | - The colour of JSON object and array delimiters, if they are displayed. 19 | - Whether to include JSON object and array delimiters in previews. 20 | - Whether to show raw JSON if it cannot be parsed without error. 21 | - The preview’s monospaced font and text size. 22 | - The body text font. 23 | - Whether preview should be display white-on-black even in Dark Mode. 24 | 25 | Changing these settings will affect previews immediately, but may not affect thumbnail until you open a folder that has not been previously opened in the current login session. 26 | 27 | ## Source Code # 28 | 29 | The source code is provided here for inspection and inspiration. The code will not build as is: graphical, other non-code resources and some code components are not included in the source release. To build *PreviewJson* from scratch, you will need to add these files yourself or remove them from your fork. 30 | 31 | The files `REPLACE_WITH_YOUR_FUNCTIONS` and `REPLACE_WITH_YOUR_CODES` must be replaced with your own files. The former will contain your `sendFeedback(_ feedback: String) -> URLSessionTask?` function. The latter your Developer Team ID, used as the App Suite identifier prefix. 32 | 33 | You will need to generate your own `Assets.xcassets` file containing the app icon and an `app_logo.png` file and `style_x.png` where x is 1-3 and are, respectively, the solid, outline and textual options presented by the **Preferences** window as True/False/Null styles. 34 | 35 | You will need to create your own `new` directory containing your own `new.html` file. 36 | 37 | ## Contributions ## 38 | 39 | Contributions are welcome, but pull requests can only be accepted when they target the `develop` branch. PRs targetting `main` will be rejected. 40 | 41 | Contributions will only be accepted if the code they contain is licensed under the terms of [the MIT Licence](#LICENSE.md) 42 | 43 | ## Release Notes 44 | 45 | - 1.1.4 *April 2025* 46 | - Better Swift string and substring handling. 47 | - Correct outlet ownership to mitigate reference cycle formation. 48 | - Remove links to deprecated PreviewText and PreviewYaml. 49 | - 1.1.3 *30 August 2024* 50 | - Correctly render the bad JSON separator line: revert NSTextViews to TextKit 1 (previously bumped to 2 by Xcode). 51 | - Improve `true`, `false` and `null` images. 52 | - Improve preference change handling. 53 | - 1.1.2 *5 May 2024* 54 | - Revise thumbnailer to improve memory utilization and efficiency. 55 | - Fix 'white flash' on opening What's New sheet. 56 | - 1.1.1 *2 March 2024* 57 | - Correct indentation. 58 | - Make tabulated preview optional. 59 | - Fix for crashes caused by very deeply nested JSON files. 60 | - 1.1.0 *25 August 2023* 61 | - Allow the user to choose the colours of strings and special values (`NaN`, `±INF`). 62 | - New columnar layout. 63 | - 1.0.4 *12 May 2023* 64 | - Fix incorrect presentation of integers `1` and `0` as booleans (thanks, anonymous). 65 | - 1.0.3 *21 January 2023* 66 | - Add link to [PreviewText](https://smittytone.net/previewtext/index.html). 67 | - Better menu handling when panels are visible. 68 | - Better app exit management. 69 | - 1.0.2 *14 December 2022* 70 | - Reduce thumbnail rendering load. 71 | - Handle dark-to-light UI mode switches. 72 | - Add App Store link. 73 | - 1.0.1 *4 October 2022* 74 | - Correct some text style discrepancies. 75 | - 1.0.0 *2 October 2022* 76 | - Initial public release. 77 | 78 | ## Copyright and Credits 79 | 80 | Primary app code and UI design © 2025, Tony Smith. 81 | -------------------------------------------------------------------------------- /JSON Previewer/Base.lproj/PreviewViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /PreviewJson/Constants.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Constants.swift 3 | * PreviewJson 4 | * 5 | * Created by Tony Smith on 12/08/2020. 6 | * Copyright © 2025 Tony Smith. All rights reserved. 7 | */ 8 | 9 | 10 | import Foundation 11 | 12 | 13 | // Combine the app's various constants into a struct 14 | struct BUFFOON_CONSTANTS { 15 | 16 | struct ERRORS { 17 | 18 | struct CODES { 19 | static let NONE = 0 20 | static let FILE_INACCESSIBLE = 400 21 | static let FILE_WONT_OPEN = 401 22 | static let BAD_MD_STRING = 402 23 | static let BAD_TS_STRING = 403 24 | } 25 | 26 | struct MESSAGES { 27 | static let NO_ERROR = "No error" 28 | static let FILE_INACCESSIBLE = "Can't access file" 29 | static let FILE_WONT_OPEN = "Can't open file" 30 | static let BAD_MD_STRING = "Can't get JSON data" 31 | static let BAD_TS_STRING = "Can't access NSTextView's TextStorage" 32 | } 33 | } 34 | 35 | struct THUMBNAIL_SIZE { 36 | 37 | static let ORIGIN_X = 0 38 | static let ORIGIN_Y = 0 39 | static let WIDTH = 768 40 | static let HEIGHT = 1024 41 | static let ASPECT = 0.75 42 | static let TAG_HEIGHT = 204.8 43 | static let FONT_SIZE = 130.0 44 | } 45 | 46 | struct ITEM_TYPE { 47 | 48 | static let KEY = 0 49 | static let VALUE = 1 50 | static let MARK_START = 2 51 | static let MARK_END = 3 52 | } 53 | 54 | struct BOOL_STYLE { 55 | 56 | static let FULL = 0 57 | static let OUTLINE = 1 58 | static let TEXT = 2 59 | } 60 | 61 | static let BASE_PREVIEW_FONT_SIZE: Float = 16.0 62 | static let BASE_THUMB_FONT_SIZE: Float = 22.0 63 | static let THUMBNAIL_LINE_COUNT = 33 64 | 65 | static let FONT_SIZE_OPTIONS: [CGFloat] = [10.0, 12.0, 14.0, 16.0, 18.0, 24.0, 28.0] 66 | 67 | static let JSON_INDENT = 8 // Can change 68 | static let BASE_INDENT = 2 // Fixed 69 | static let TABBED_INDENT = 4 // Fixed 70 | 71 | static let URL_MAIN = "https://smittytone.net/previewjson/index.html" 72 | static let APP_STORE = "https://apps.apple.com/us/app/previewjson/id6443584377?ls" 73 | static let SUITE_NAME = ".suite.preview-previewjson" 74 | static let APP_CODE_PREVIEWER = "com.bps.previewjson.JSON-Previewer" 75 | 76 | static let BODY_FONT_NAME = "Menlo-Regular" 77 | static let KEY_COLOUR_HEX = "FF2600FF" 78 | static let MARK_COLOUR_HEX = "929292FF" 79 | 80 | static let RENDER_DEBUG = false 81 | 82 | // FROM 1.0.3 83 | struct APP_URLS { 84 | 85 | static let PM = "https://apps.apple.com/us/app/previewmarkdown/id1492280469?ls=1" 86 | static let PC = "https://apps.apple.com/us/app/previewcode/id1571797683?ls=1" 87 | static let PY = "https://apps.apple.com/us/app/previewyaml/id1564574724?ls=1" 88 | static let PJ = "https://apps.apple.com/us/app/previewjson/id6443584377?ls=1" 89 | static let PT = "https://apps.apple.com/us/app/previewtext/id1660037028?ls=1" 90 | } 91 | 92 | static let WHATS_NEW_PREF = "com-bps-previewjson-do-show-whats-new-" 93 | 94 | // FROM 1.1.0 95 | static let STRING_COLOUR_HEX = "FC6A5DFF" 96 | static let SPECIAL_COLOUR_HEX = "D0BF69FF" 97 | 98 | struct PREFS_KEYS { 99 | 100 | static let BODY_FONT = "com-bps-previewjson-base-font-name" 101 | static let BODY_SIZE = "com-bps-previewjson-base-font-size" 102 | static let THUMB_SIZE = "com-bps-previewjson-thumb-font-size" 103 | static let KEY_COLOUR = "com-bps-previewjson-code-colour-hex" 104 | static let MARK_COLOUR = "com-bps-previewjson-mark-colour-hex" 105 | static let STRING_COLOUR = "com-bps-previewyaml-string-colour-hex" 106 | static let SPECIAL_COLOUR = "com-bps-previewyaml-special-colour-hex" 107 | static let USE_LIGHT = "com-bps-previewjson-do-use-light" 108 | static let WHATS_NEW = "com-bps-previewyaml-do-show-whats-new-" 109 | static let INDENT = "com-bps-previewjson-json-indent" 110 | static let SCALARS = "com-bps-previewjson-do-indent-scalars" 111 | static let BAD = "com-bps-previewjson-show-bad-json" 112 | static let BOOL_STYLE = "com-bps-previewjson-bool-style" 113 | } 114 | 115 | // FROM 1.1.1 116 | static let TABULATION_INDENT_VALUE = 999 117 | } 118 | -------------------------------------------------------------------------------- /JSON Thumbnailer/ThumbnailProvider.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * ThumbnailProvider.swift 3 | * PreviewJson 4 | * 5 | * Created by Tony Smith on 01/09/2023. 6 | * Copyright © 2025 Tony Smith. All rights reserved. 7 | */ 8 | 9 | 10 | import Foundation 11 | import AppKit 12 | import QuickLookThumbnailing 13 | 14 | 15 | class ThumbnailProvider: QLThumbnailProvider { 16 | 17 | // MARK:- Private Properties 18 | 19 | private enum ThumbnailerError: Error { 20 | case badFileLoad(String) 21 | case badFileUnreadable(String) 22 | case badFileUnsupportedEncoding(String) 23 | case badFileUnsupportedFile(String) 24 | case badGfxBitmap 25 | case badGfxDraw 26 | } 27 | 28 | 29 | // MARK:- QLThumbnailProvider Required Functions 30 | 31 | override func provideThumbnail(for request: QLFileThumbnailRequest, _ handler: @escaping (QLThumbnailReply?, Error?) -> Void) { 32 | 33 | /* 34 | * This is the main entry point for macOS' thumbnailing system 35 | */ 36 | 37 | // Load the source file using a co-ordinator as we don't know what thread this function 38 | // will be executed in when it's called by macOS' QuickLook code 39 | if FileManager.default.isReadableFile(atPath: request.fileURL.path) { 40 | // Only proceed if the file is accessible from here 41 | do { 42 | // Get the file contents as a string, making sure it's not cached 43 | // as we're not going to read it again any time soon 44 | let data: Data = try Data.init(contentsOf: request.fileURL, options: [.uncached]) 45 | 46 | // Get the string's encoding, or fail back to .utf8 47 | let encoding: String.Encoding = data.stringEncoding ?? .utf8 48 | 49 | // Check the string's encoding generates a valid string 50 | // NOTE This may not be necessary and so may be removed 51 | guard let _: String = String.init(data: data, encoding: encoding) else { 52 | handler(nil, ThumbnailerError.badFileLoad(request.fileURL.path)) 53 | return 54 | } 55 | 56 | // Instantiate the common code within the closure 57 | let common: Common = Common.init(true) 58 | 59 | // Set the primary drawing frame and a base font size 60 | let jsonFrame: CGRect = NSMakeRect(CGFloat(BUFFOON_CONSTANTS.THUMBNAIL_SIZE.ORIGIN_X), 61 | CGFloat(BUFFOON_CONSTANTS.THUMBNAIL_SIZE.ORIGIN_Y), 62 | CGFloat(BUFFOON_CONSTANTS.THUMBNAIL_SIZE.WIDTH), 63 | CGFloat(BUFFOON_CONSTANTS.THUMBNAIL_SIZE.HEIGHT)) 64 | 65 | // Instantiate an NSTextField to display the NSAttributedString render of the JSON 66 | let jsonTextField: NSTextField = NSTextField.init(frame: jsonFrame) 67 | jsonTextField.attributedStringValue = common.getAttributedString(data) 68 | 69 | // Generate the bitmap from the rendered JSON text view 70 | guard let bodyImageRep: NSBitmapImageRep = jsonTextField.bitmapImageRepForCachingDisplay(in: jsonFrame) else { 71 | handler(nil, ThumbnailerError.badGfxBitmap) 72 | return 73 | } 74 | 75 | // Draw the code view into the bitmap 76 | jsonTextField.cacheDisplay(in: jsonFrame, to: bodyImageRep) 77 | 78 | if let image: CGImage = bodyImageRep.cgImage { 79 | // Just in case, make a copy of the cgImage, in case 80 | // `bodyImageReg` is freed 81 | if let cgImage: CGImage = image.copy() { 82 | // Calculate image scaling, frame size, etc. 83 | let thumbnailFrame: CGRect = NSMakeRect(0.0, 84 | 0.0, 85 | CGFloat(BUFFOON_CONSTANTS.THUMBNAIL_SIZE.ASPECT) * request.maximumSize.height, 86 | request.maximumSize.height) 87 | let scaleFrame: CGRect = NSMakeRect(0.0, 88 | 0.0, 89 | thumbnailFrame.width * request.scale, 90 | thumbnailFrame.height * request.scale) 91 | 92 | // Pass a QLThumbnailReply and no error to the supplied handler 93 | handler(QLThumbnailReply.init(contextSize: thumbnailFrame.size) { (context) -> Bool in 94 | // `scaleFrame` and `cgImage` are immutable 95 | context.draw(cgImage, in: scaleFrame, byTiling: false) 96 | return true 97 | }, nil) 98 | return 99 | } 100 | } 101 | 102 | handler(nil, ThumbnailerError.badGfxDraw) 103 | return 104 | } catch { 105 | // NOP: fall through to error 106 | } 107 | } 108 | 109 | // We didn't draw anything because of 'can't find file' error 110 | handler(nil, ThumbnailerError.badFileUnreadable(request.fileURL.path)) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /PreviewJson/GenericFontExtensions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * GenericFontExtensions.swift 3 | * PreviewApps 4 | * 5 | * These functions can be used by all PreviewApps 6 | * 7 | * Created by Tony Smith on 18/06/2021. 8 | * Copyright © 2025 Tony Smith. All rights reserved. 9 | */ 10 | 11 | 12 | import Foundation 13 | import Cocoa 14 | import WebKit 15 | import UniformTypeIdentifiers 16 | 17 | 18 | extension AppDelegate { 19 | 20 | // MARK: - Font Management 21 | 22 | /** 23 | Build a list of available fonts. 24 | 25 | Should be called asynchronously. Two sets created: monospace fonts and regular fonts. 26 | Requires 'bodyFonts' and 'codeFonts' to be set as instance properties. 27 | Comment out either of these, as required. 28 | 29 | The final font lists each comprise pairs of strings: the font's PostScript name 30 | then its display name. 31 | */ 32 | internal func asyncGetFonts() { 33 | 34 | var cf: [PMFont] = [] 35 | let monoTrait: UInt = NSFontTraitMask.fixedPitchFontMask.rawValue 36 | let fm: NSFontManager = NSFontManager.shared 37 | let families: [String] = fm.availableFontFamilies 38 | for family in families { 39 | // Remove known unwanted fonts 40 | if family.hasPrefix(".") || family == "Apple Braille" || family == "Apple Color Emoji" { 41 | continue 42 | } 43 | 44 | // For each family, examine its fonts for suitable ones 45 | if let fonts: [[Any]] = fm.availableMembers(ofFontFamily: family) { 46 | // This will hold a font family: individual fonts will be added to 47 | // the 'styles' array 48 | var familyRecord: PMFont = PMFont.init() 49 | familyRecord.displayName = family 50 | 51 | for font: [Any] in fonts { 52 | let fontTraits: UInt = font[3] as! UInt 53 | if monoTrait & fontTraits != 0 { 54 | // The font is good to use, so add it to the list 55 | var fontRecord: PMFont = PMFont.init() 56 | fontRecord.postScriptName = font[0] as! String 57 | fontRecord.styleName = font[1] as! String 58 | fontRecord.traits = fontTraits 59 | 60 | if familyRecord.styles == nil { 61 | familyRecord.styles = [] 62 | } 63 | 64 | familyRecord.styles!.append(fontRecord) 65 | } 66 | } 67 | 68 | if familyRecord.styles != nil && familyRecord.styles!.count > 0 { 69 | cf.append(familyRecord) 70 | } 71 | } 72 | } 73 | 74 | DispatchQueue.main.async { 75 | self.codeFonts = cf 76 | } 77 | } 78 | 79 | 80 | /** 81 | Build and enable the font style popup. 82 | 83 | - Parameters: 84 | - styleName: The name of currently selected style, or nil to select the first one. 85 | */ 86 | internal func setStylePopup(_ styleName: String? = nil) { 87 | 88 | if let selectedFamily: String = self.codeFontPopup.titleOfSelectedItem { 89 | self.codeStylePopup.removeAllItems() 90 | for family: PMFont in self.codeFonts { 91 | if selectedFamily == family.displayName { 92 | if let styles: [PMFont] = family.styles { 93 | self.codeStylePopup.isEnabled = true 94 | for style: PMFont in styles { 95 | self.codeStylePopup.addItem(withTitle: style.styleName) 96 | } 97 | 98 | if styleName != nil { 99 | self.codeStylePopup.selectItem(withTitle: styleName!) 100 | } 101 | } 102 | } 103 | } 104 | } 105 | } 106 | 107 | 108 | /** 109 | Select the font popup using the stored PostScript name 110 | of the user's chosen font. 111 | 112 | - Parameters: 113 | - postScriptName: The PostScript name of the font. 114 | */ 115 | internal func selectFontByPostScriptName(_ postScriptName: String) { 116 | 117 | for family: PMFont in self.codeFonts { 118 | if let styles: [PMFont] = family.styles { 119 | for style: PMFont in styles { 120 | if style.postScriptName == postScriptName { 121 | self.codeFontPopup.selectItem(withTitle: family.displayName) 122 | setStylePopup(style.styleName) 123 | } 124 | } 125 | } 126 | } 127 | } 128 | 129 | 130 | /** 131 | Get the PostScript name from the selected family and style. 132 | 133 | - Returns: The PostScript name as a string, or nil. 134 | */ 135 | internal func getPostScriptName() -> String? { 136 | 137 | if let selectedFont: String = self.codeFontPopup.titleOfSelectedItem { 138 | let selectedStyle: Int = codeStylePopup.indexOfSelectedItem 139 | for family: PMFont in self.codeFonts { 140 | if family.displayName == selectedFont { 141 | if let styles: [PMFont] = family.styles { 142 | let font: PMFont = styles[selectedStyle] 143 | return font.postScriptName 144 | } 145 | } 146 | } 147 | } 148 | 149 | return nil 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /RenderDemo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // RenderDemo 4 | // 5 | // Created by Tony Smith on 10/07/2023. 6 | // 7 | 8 | import Cocoa 9 | 10 | @main 11 | class AppDelegate: NSObject, 12 | NSApplicationDelegate, 13 | NSOpenSavePanelDelegate { 14 | 15 | // MARK: - Class UI Properies 16 | 17 | @IBOutlet weak var window: NSWindow! 18 | @IBOutlet weak var mainView: NSView! 19 | @IBOutlet weak var previewTextView: NSTextView! 20 | @IBOutlet weak var previewScrollView: NSScrollView! 21 | @IBOutlet weak var modeButton: NSButton! 22 | @IBOutlet weak var indentButton: NSButton! 23 | 24 | 25 | // MARK: - Private Properies 26 | 27 | private var openDialog: NSOpenPanel? = nil 28 | private var currentURL: URL? = nil 29 | private var renderAsDark: Bool = true 30 | private var renderIndents: Bool = false 31 | private var common: Common = Common.init(false) 32 | 33 | 34 | // MARK: - Class Lifecycle Functions 35 | 36 | func applicationDidFinishLaunching(_ notification: Notification) { 37 | 38 | // Set the mode button 39 | self.modeButton.state = self.renderAsDark ? .on : .off 40 | self.indentButton.state = self.renderIndents ? .on : .off 41 | 42 | // Centre the main window and display 43 | self.window.center() 44 | self.window.makeKeyAndOrderFront(self) 45 | } 46 | 47 | 48 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 49 | 50 | // When the main window closed, shut down the app 51 | return true 52 | } 53 | 54 | 55 | // MARK: - Action Functions 56 | 57 | @IBAction private func doLoadFile(_ sender: Any) { 58 | 59 | self.openDialog = NSOpenPanel.init() 60 | self.openDialog!.canChooseFiles = true 61 | self.openDialog!.canChooseDirectories = false 62 | self.openDialog!.allowsMultipleSelection = false 63 | self.openDialog!.delegate = self 64 | self.openDialog!.directoryURL = URL.init(fileURLWithPath: "") 65 | 66 | if self.openDialog!.runModal() == .OK { 67 | self.currentURL = self.openDialog!.url 68 | let possibleError: NSError? = renderContent(self.openDialog!.url) 69 | if possibleError != nil { 70 | let errorAlert: NSAlert = NSAlert.init(error: possibleError!) 71 | errorAlert.beginSheetModal(for: self.window) 72 | } 73 | } 74 | 75 | self.openDialog = nil 76 | } 77 | 78 | 79 | @IBAction private func doSwitchMode(_ sender: Any) { 80 | 81 | self.renderAsDark = self.modeButton.state == .on 82 | doReRenderFile(self) 83 | } 84 | 85 | 86 | @IBAction private func doReRenderFile(_ sender: Any) { 87 | 88 | let possibleError: NSError? = renderContent(self.currentURL) 89 | if possibleError != nil { 90 | // Pop up an alert 91 | let errorAlert: NSAlert = NSAlert.init(error: possibleError!) 92 | errorAlert.beginSheetModal(for: self.window) 93 | } 94 | } 95 | 96 | 97 | @IBAction private func doSetIndentCharacter(_ sender: Any) { 98 | 99 | self.renderIndents = self.indentButton.state == .on 100 | doReRenderFile(self) 101 | } 102 | 103 | 104 | // MARK: - Rendering Functions 105 | 106 | func renderContent(_ fileToRender: URL?) -> NSError? { 107 | 108 | var reportError: NSError? = nil 109 | 110 | do { 111 | if let yamlUrl: URL = fileToRender { 112 | self.window.title = yamlUrl.absoluteString 113 | 114 | // Get the file contents as a string 115 | let data: Data = try Data.init(contentsOf: yamlUrl, options: [.uncached]) 116 | 117 | // Get the string's encoding, or fail back to .utf8 118 | let encoding: String.Encoding = data.stringEncoding ?? .utf8 119 | 120 | if let jsonString: String = String.init(data: data, encoding: encoding) { 121 | common.doShowLightBackground = !self.renderAsDark 122 | common.doUseSpecialIndentChar = self.renderIndents 123 | common.resetStylesOnModeChange() 124 | 125 | let regexTrue = try! NSRegularExpression(pattern: ":[\\s]*true") 126 | let jsonStringTrue: String = regexTrue.stringByReplacingMatches(in: jsonString, 127 | options: [], 128 | range: NSMakeRange(0, jsonString.count), 129 | withTemplate: ": \"JSON-TRUE\"") 130 | 131 | let regexFalse = try! NSRegularExpression(pattern: ":[\\s]*false") 132 | let jsonStringFalse: String = regexFalse.stringByReplacingMatches(in: jsonStringTrue, 133 | options: [], 134 | range: NSMakeRange(0, jsonStringTrue.count), 135 | withTemplate: ": \"JSON-FALSE\"") 136 | 137 | // Get the key string first 138 | let jsonDataCoded: Data = jsonStringFalse.data(using: encoding) ?? data 139 | let jsonAttString: NSAttributedString = common.getAttributedString(jsonDataCoded) 140 | 141 | self.previewTextView.backgroundColor = common.doShowLightBackground ? NSColor.init(white: 1.0, alpha: 0.9) : NSColor.textBackgroundColor 142 | self.previewScrollView.scrollerKnobStyle = common.doShowLightBackground ? .dark : .light 143 | 144 | if let renderTextStorage: NSTextStorage = self.previewTextView.textStorage { 145 | renderTextStorage.beginEditing() 146 | renderTextStorage.setAttributedString(jsonAttString) 147 | renderTextStorage.endEditing() 148 | return nil 149 | } 150 | 151 | // We can't access the preview NSTextView's NSTextStorage 152 | reportError = setError(BUFFOON_CONSTANTS.ERRORS.CODES.BAD_TS_STRING) 153 | } else { 154 | // We couldn't convert to data to a valid encoding 155 | let errDesc: String = "\(BUFFOON_CONSTANTS.ERRORS.MESSAGES.BAD_TS_STRING) \(encoding)" 156 | reportError = NSError(domain: BUFFOON_CONSTANTS.APP_CODE_PREVIEWER, 157 | code: BUFFOON_CONSTANTS.ERRORS.CODES.BAD_MD_STRING, 158 | userInfo: [NSLocalizedDescriptionKey: errDesc]) 159 | } 160 | } else { 161 | // No file selected 162 | let errDesc: String = "No file selected to render" 163 | reportError = NSError(domain: BUFFOON_CONSTANTS.APP_CODE_PREVIEWER, 164 | code: BUFFOON_CONSTANTS.ERRORS.CODES.BAD_MD_STRING, 165 | userInfo: [NSLocalizedDescriptionKey: errDesc]) 166 | } 167 | } catch { 168 | // We couldn't read the file so set an appropriate error to report back 169 | reportError = setError(BUFFOON_CONSTANTS.ERRORS.CODES.FILE_WONT_OPEN) 170 | } 171 | 172 | return reportError 173 | } 174 | 175 | 176 | /** 177 | Generate an NSError for an internal error, specified by its code. 178 | 179 | Codes are listed in `Constants.swift` 180 | 181 | - Parameters: 182 | - code: The internal error code. 183 | 184 | - Returns: The described error as an NSError. 185 | */ 186 | func setError(_ code: Int) -> NSError { 187 | 188 | var errDesc: String 189 | 190 | switch(code) { 191 | case BUFFOON_CONSTANTS.ERRORS.CODES.FILE_INACCESSIBLE: 192 | errDesc = BUFFOON_CONSTANTS.ERRORS.MESSAGES.FILE_INACCESSIBLE 193 | case BUFFOON_CONSTANTS.ERRORS.CODES.FILE_WONT_OPEN: 194 | errDesc = BUFFOON_CONSTANTS.ERRORS.MESSAGES.FILE_WONT_OPEN 195 | case BUFFOON_CONSTANTS.ERRORS.CODES.BAD_TS_STRING: 196 | errDesc = BUFFOON_CONSTANTS.ERRORS.MESSAGES.BAD_TS_STRING 197 | case BUFFOON_CONSTANTS.ERRORS.CODES.BAD_MD_STRING: 198 | errDesc = BUFFOON_CONSTANTS.ERRORS.MESSAGES.BAD_MD_STRING 199 | default: 200 | errDesc = "UNKNOWN ERROR" 201 | } 202 | 203 | return NSError(domain: BUFFOON_CONSTANTS.APP_CODE_PREVIEWER, 204 | code: code, 205 | userInfo: [NSLocalizedDescriptionKey: errDesc]) 206 | } 207 | 208 | } 209 | -------------------------------------------------------------------------------- /JSON Previewer/PreviewViewController.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * PreviewViewController.swift 3 | * PreviewJson 4 | * 5 | * Created by Tony Smith on 29/08/2023. 6 | * Copyright © 2025 Tony Smith. All rights reserved. 7 | */ 8 | 9 | 10 | import Cocoa 11 | import Quartz 12 | 13 | 14 | class PreviewViewController: NSViewController, 15 | QLPreviewingController { 16 | 17 | 18 | // MARK: - Class UI Properties 19 | 20 | @IBOutlet weak var renderTextView: NSTextView! 21 | @IBOutlet weak var renderTextScrollView: NSScrollView! 22 | 23 | override var nibName: NSNib.Name? { 24 | return NSNib.Name("PreviewViewController") 25 | } 26 | 27 | 28 | func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) { 29 | 30 | /* 31 | * Main entry point for the macOS preview system 32 | */ 33 | 34 | // Get an error message ready for use 35 | var reportError: NSError? = nil 36 | 37 | // Hide the error message field 38 | self.renderTextScrollView.isHidden = false 39 | 40 | // Instantiate the common renderer 41 | let common: Common = Common.init() 42 | 43 | // Load the source file using a co-ordinator as we don't know what thread this function 44 | // will be executed in when it's called by macOS' QuickLook code 45 | if FileManager.default.isReadableFile(atPath: url.path) { 46 | // Only proceed if the file is accessible from here 47 | do { 48 | // Get the file contents as a string 49 | let data: Data = try Data.init(contentsOf: url, options: [.uncached]) 50 | let encoding: String.Encoding = data.stringEncoding ?? .utf8 51 | 52 | if let jsonString: String = String.init(data: data, encoding: encoding) { 53 | // FROM 1.0.4 54 | // Scan for JSON booleans and replace with a marker string. 55 | // This is to deal with the issue with NSJsonSerialization which causes 56 | // booleans to be replaced with 1 or 0 and therefore indistinguishable 57 | // from integer 1 or 0. Maybe there is a better option? 58 | let regexTrue = try! NSRegularExpression(pattern: ":[\\s]*true") 59 | let jsonStringTrue: String = regexTrue.stringByReplacingMatches(in: jsonString, 60 | options: [], 61 | range: NSMakeRange(0, jsonString.count), 62 | withTemplate: ": \"PREVIEW-JSON-TRUE\"") 63 | 64 | let regexFalse = try! NSRegularExpression(pattern: ":[\\s]*false") 65 | let jsonStringFalse: String = regexFalse.stringByReplacingMatches(in: jsonStringTrue, 66 | options: [], 67 | range: NSMakeRange(0, jsonStringTrue.count), 68 | withTemplate: ": \"PREVIEW-JSON-FALSE\"") 69 | 70 | // Get the key string first 71 | let jsonDataCoded: Data = jsonStringFalse.data(using: encoding) ?? data 72 | let jsonAttString: NSAttributedString = common.getAttributedString(jsonDataCoded) 73 | 74 | // Knock back the light background to make the scroll bars visible in dark mode 75 | // NOTE If !doShowLightBackground, 76 | // in light mode, the scrollers show up dark-on-light, in dark mode light-on-dark 77 | // If doShowLightBackground, 78 | // in light mode, the scrollers show up light-on-light, in dark mode light-on-dark 79 | // NOTE Changing the scrollview scroller knob style has no effect 80 | self.renderTextView.backgroundColor = common.doShowLightBackground ? NSColor.init(white: 1.0, alpha: 0.9) : NSColor.textBackgroundColor 81 | self.renderTextScrollView.scrollerKnobStyle = common.doShowLightBackground ? .dark : .light 82 | 83 | if let renderTextStorage: NSTextStorage = self.renderTextView.textStorage { 84 | /* 85 | * NSTextStorage subclasses that return true from the fixesAttributesLazily 86 | * method should avoid directly calling fixAttributes(in:) or else bracket 87 | * such calls with beginEditing() and endEditing() messages. 88 | */ 89 | renderTextStorage.beginEditing() 90 | renderTextStorage.setAttributedString(jsonAttString) 91 | renderTextStorage.endEditing() 92 | 93 | // Add the subview to the instance's own view and draw 94 | self.view.display() 95 | 96 | // Call the QLPreviewingController indicating no error 97 | // (argument is nil) 98 | handler(nil) 99 | return 100 | } 101 | 102 | // We can't access the preview NSTextView's NSTextStorage 103 | reportError = setError(BUFFOON_CONSTANTS.ERRORS.CODES.BAD_TS_STRING) 104 | } else { 105 | // We couldn't convert to data to a valid encoding 106 | let errDesc: String = "\(BUFFOON_CONSTANTS.ERRORS.MESSAGES.BAD_TS_STRING) \(encoding)" 107 | reportError = NSError(domain: BUFFOON_CONSTANTS.APP_CODE_PREVIEWER, 108 | code: BUFFOON_CONSTANTS.ERRORS.CODES.BAD_MD_STRING, 109 | userInfo: [NSLocalizedDescriptionKey: errDesc]) 110 | } 111 | } catch { 112 | // We couldn't read the file so set an appropriate error to report back 113 | reportError = setError(BUFFOON_CONSTANTS.ERRORS.CODES.FILE_WONT_OPEN) 114 | } 115 | } else { 116 | // We couldn't access the file so set an appropriate error to report back 117 | reportError = setError(BUFFOON_CONSTANTS.ERRORS.CODES.FILE_INACCESSIBLE) 118 | } 119 | 120 | // Display the error locally in the window 121 | showError(reportError!.userInfo[NSLocalizedDescriptionKey] as! String) 122 | 123 | // Call the QLPreviewingController indicating an error 124 | // (argumnet is not nil) 125 | handler(reportError) 126 | } 127 | 128 | 129 | /* 130 | * Implement this method and set QLSupportsSearchableItems to YES in the Info.plist of the extension if you support CoreSpotlight. 131 | * 132 | func preparePreviewOfSearchableItem(identifier: String, queryString: String?, completionHandler handler: @escaping (Error?) -> Void) { 133 | // Perform any setup necessary in order to prepare the view. 134 | 135 | // Call the completion handler so Quick Look knows that the preview is fully loaded. 136 | // Quick Look will display a loading spinner while the completion handler is not called. 137 | handler(nil) 138 | } 139 | */ 140 | 141 | 142 | // MARK: - Utility Functions 143 | 144 | /** 145 | Place an error message in its various outlets. 146 | 147 | - parameters: 148 | - errString: The error message. 149 | */ 150 | func showError(_ errString: String) { 151 | 152 | NSLog("BUFFOON \(errString)") 153 | self.renderTextScrollView.isHidden = true 154 | self.view.display() 155 | } 156 | 157 | 158 | /** 159 | Generate an NSError for an internal error, specified by its code. 160 | 161 | Codes are listed in `Constants.swift` 162 | 163 | - Parameters: 164 | - code: The internal error code. 165 | 166 | - Returns: The described error as an NSError. 167 | */ 168 | func setError(_ code: Int) -> NSError { 169 | 170 | var errDesc: String 171 | 172 | switch(code) { 173 | case BUFFOON_CONSTANTS.ERRORS.CODES.FILE_INACCESSIBLE: 174 | errDesc = BUFFOON_CONSTANTS.ERRORS.MESSAGES.FILE_INACCESSIBLE 175 | case BUFFOON_CONSTANTS.ERRORS.CODES.FILE_WONT_OPEN: 176 | errDesc = BUFFOON_CONSTANTS.ERRORS.MESSAGES.FILE_WONT_OPEN 177 | case BUFFOON_CONSTANTS.ERRORS.CODES.BAD_TS_STRING: 178 | errDesc = BUFFOON_CONSTANTS.ERRORS.MESSAGES.BAD_TS_STRING 179 | case BUFFOON_CONSTANTS.ERRORS.CODES.BAD_MD_STRING: 180 | errDesc = BUFFOON_CONSTANTS.ERRORS.MESSAGES.BAD_MD_STRING 181 | default: 182 | errDesc = "UNKNOWN ERROR" 183 | } 184 | 185 | return NSError(domain: BUFFOON_CONSTANTS.APP_CODE_PREVIEWER, 186 | code: code, 187 | userInfo: [NSLocalizedDescriptionKey: errDesc]) 188 | } 189 | 190 | } 191 | -------------------------------------------------------------------------------- /PreviewJson/GenericExtensions.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * GenericExtensions.swift 3 | * PreviewApps 4 | * 5 | * These functions can be used by all PreviewApps 6 | * 7 | * Created by Tony Smith on 18/06/2021. 8 | * Copyright © 2025 Tony Smith. All rights reserved. 9 | */ 10 | 11 | 12 | import Foundation 13 | import Cocoa 14 | import WebKit 15 | import UniformTypeIdentifiers 16 | 17 | 18 | extension AppDelegate { 19 | 20 | // MARK: - Process Handling Functions 21 | 22 | /** 23 | Generic macOS process creation and run function. 24 | 25 | Make sure we clear the preference flag for this minor version, so that 26 | the sheet is not displayed next time the app is run (unless the version changes) 27 | 28 | - Parameters: 29 | - app: The location of the app. 30 | - with: Array of arguments to pass to the app. 31 | 32 | - Returns: `true` if the operation was successful, otherwise `false`. 33 | */ 34 | internal func runProcess(app path: String, with args: [String]) -> Bool { 35 | 36 | let task: Process = Process() 37 | task.executableURL = URL.init(fileURLWithPath: path) 38 | task.arguments = args 39 | 40 | // Pipe out the output to avoid putting it in the log 41 | let outputPipe = Pipe() 42 | task.standardOutput = outputPipe 43 | task.standardError = outputPipe 44 | 45 | do { 46 | try task.run() 47 | } catch { 48 | return false 49 | } 50 | 51 | // Block until the task has completed (short tasks ONLY) 52 | task.waitUntilExit() 53 | 54 | if !task.isRunning { 55 | if (task.terminationStatus != 0) { 56 | // Command failed -- collect the output if there is any 57 | let outputHandle = outputPipe.fileHandleForReading 58 | var outString: String = "" 59 | if let line = String(data: outputHandle.availableData, encoding: String.Encoding.utf8) { 60 | outString = line 61 | } 62 | 63 | if outString.count > 0 { 64 | print("\(outString)") 65 | } else { 66 | print("Error", "Exit code \(task.terminationStatus)") 67 | } 68 | return false 69 | } 70 | } 71 | 72 | return true 73 | } 74 | 75 | 76 | // MARK: - Misc Functions 77 | 78 | /** 79 | Present an error message specific to sending feedback. 80 | 81 | This is called from multiple locations: if the initial request can't be created, 82 | there was a send failure, or a server error. 83 | */ 84 | internal func sendFeedbackError() { 85 | 86 | let alert: NSAlert = showAlert("Feedback Could Not Be Sent", 87 | "Unfortunately, your comments could not be send at this time. Please try again later.") 88 | alert.beginSheetModal(for: self.reportWindow) { (resp) in 89 | self.window.endSheet(self.reportWindow) 90 | self.showPanelGenerators() 91 | } 92 | } 93 | 94 | 95 | /** 96 | Generic alert generator. 97 | 98 | - Parameters: 99 | - head: The alert's title. 100 | - message: The alert's message. 101 | - addOkButton: Should we add an OK button? 102 | 103 | - Returns: The NSAlert. 104 | */ 105 | internal func showAlert(_ head: String, _ message: String, _ addOkButton: Bool = true) -> NSAlert { 106 | 107 | let alert: NSAlert = NSAlert() 108 | alert.messageText = head 109 | alert.informativeText = message 110 | if addOkButton { alert.addButton(withTitle: "OK") } 111 | return alert 112 | } 113 | 114 | 115 | /** 116 | Build a basic 'major.manor' version string for prefs usage. 117 | 118 | - Returns: The version string. 119 | */ 120 | internal func getVersion() -> String { 121 | 122 | let version: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String 123 | let parts: [String] = (version as NSString).components(separatedBy: ".") 124 | return parts[0] + "-" + parts[1] 125 | } 126 | 127 | 128 | /** 129 | Build a date string string for feedback usage. 130 | 131 | - Returns: The date string. 132 | */ 133 | internal func getDateForFeedback() -> String { 134 | 135 | let date: Date = Date() 136 | let dateFormatter: DateFormatter = DateFormatter() 137 | dateFormatter.locale = Locale(identifier: "en_US_POSIX") 138 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 139 | dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) 140 | return dateFormatter.string(from: date) 141 | } 142 | 143 | 144 | /** 145 | Build a user-agent string string for feedback usage. 146 | 147 | - Returns: The user-agent string. 148 | */ 149 | internal func getUserAgentForFeedback() -> String { 150 | 151 | // Refactor code out into separate function for clarity 152 | 153 | let sysVer: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion 154 | let bundle: Bundle = Bundle.main 155 | let app: String = bundle.object(forInfoDictionaryKey: "CFBundleExecutable") as! String 156 | let version: String = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String 157 | let build: String = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as! String 158 | return "\(app)/\(version)-\(build) (macOS/\(sysVer.majorVersion).\(sysVer.minorVersion).\(sysVer.patchVersion))" 159 | } 160 | 161 | 162 | /** 163 | Read back the host system's registered UTI for the specified file. 164 | 165 | This is not PII. It used solely for debugging purposes 166 | 167 | - Parameters: 168 | - filename: The file we'll use to get the UTI. 169 | 170 | - Returns: The file's UTI. 171 | */ 172 | internal func getLocalFileUTI(_ filename: String) -> String { 173 | 174 | var localUTI: String = "NONE" 175 | let samplePath = Bundle.main.resourcePath! + "/" + filename 176 | 177 | if FileManager.default.fileExists(atPath: samplePath) { 178 | // Create a URL reference to the sample file 179 | let sampleURL = URL.init(fileURLWithPath: samplePath) 180 | 181 | do { 182 | // Read back the UTI from the URL 183 | // Use Big Sur's UTType API 184 | if #available(macOS 11, *) { 185 | if let uti: UTType = try sampleURL.resourceValues(forKeys: [.contentTypeKey]).contentType { 186 | localUTI = uti.identifier 187 | } 188 | } else { 189 | // NOTE '.typeIdentifier' yields an optional 190 | if let uti: String = try sampleURL.resourceValues(forKeys: [.typeIdentifierKey]).typeIdentifier { 191 | localUTI = uti 192 | } 193 | } 194 | } catch { 195 | // NOP 196 | } 197 | } 198 | 199 | return localUTI 200 | } 201 | 202 | 203 | /** 204 | Disable all panel-opening menu items. 205 | */ 206 | internal func hidePanelGenerators() { 207 | 208 | self.helpMenuReportBug.isEnabled = false 209 | self.helpMenuWhatsNew.isEnabled = false 210 | self.mainMenuSettings.isEnabled = false 211 | } 212 | 213 | 214 | /** 215 | Enable all panel-opening menu items. 216 | */ 217 | internal func showPanelGenerators() { 218 | 219 | self.helpMenuReportBug.isEnabled = true 220 | self.helpMenuWhatsNew.isEnabled = true 221 | self.mainMenuSettings.isEnabled = true 222 | } 223 | 224 | 225 | /** 226 | Get system and state information and record it for use during run. 227 | */ 228 | internal func recordSystemState() { 229 | 230 | // First ensure we are running on Mojave or above - Dark Mode is not supported by earlier versons 231 | let sysVer: OperatingSystemVersion = ProcessInfo.processInfo.operatingSystemVersion 232 | self.isMontereyPlus = (sysVer.majorVersion >= 12) 233 | } 234 | 235 | 236 | /** 237 | Determine whether the host Mac is in light mode. 238 | 239 | - Returns: `true` if the Mac is in light mode, otherwise `false`. 240 | */ 241 | internal func isMacInLightMode() -> Bool { 242 | 243 | let appearNameString: String = NSApp.effectiveAppearance.name.rawValue 244 | return (appearNameString == "NSAppearanceNameAqua") 245 | } 246 | 247 | 248 | // MARK: - URLSession Delegate Functions 249 | 250 | func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) { 251 | 252 | // Some sort of connection error - report it 253 | self.connectionProgress.stopAnimation(self) 254 | sendFeedbackError() 255 | } 256 | 257 | 258 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 259 | 260 | // The operation to send the comment completed 261 | self.connectionProgress.stopAnimation(self) 262 | if let _ = error { 263 | // An error took place - report it 264 | sendFeedbackError() 265 | } else { 266 | // The comment was submitted successfully 267 | let alert: NSAlert = showAlert("Thanks For Your Feedback!", 268 | "Your comments have been received and we’ll take a look at them shortly.") 269 | alert.beginSheetModal(for: self.reportWindow) { (resp) in 270 | // Close the feedback window when the modal alert returns 271 | let _: Timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: false) { timer in 272 | self.window.endSheet(self.reportWindow) 273 | self.showPanelGenerators() 274 | } 275 | } 276 | } 277 | } 278 | 279 | 280 | // MARK: - WKWebNavigation Delegate Functions 281 | 282 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 283 | 284 | // Asynchronously show the sheet once the HTML has loaded 285 | // (triggered by delegate method) 286 | 287 | if let nav = self.whatsNewNav { 288 | if nav == navigation { 289 | // Display the sheet 290 | // FROM 1.1.2 -- add timer to prevent 'white flash' 291 | Timer.scheduledTimer(withTimeInterval: 0.05, repeats: false) { timer in 292 | timer.invalidate() 293 | self.window.beginSheet(self.whatsNewWindow, completionHandler: nil) 294 | } 295 | } 296 | } 297 | } 298 | } 299 | 300 | 301 | extension NSApplication { 302 | 303 | func isMacInLightMode() -> Bool { 304 | 305 | return (self.effectiveAppearance.name.rawValue == "NSAppearanceNameAqua") 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /RenderDemo/Base.lproj/MainMenu.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 188 | 189 | 190 | 191 | 192 | 193 | 206 | 220 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | -------------------------------------------------------------------------------- /JSON Previewer/Common.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Common.swift 3 | * PreviewJson 4 | * Code common to Json Previewer and Json Thumbnailer 5 | * 6 | * Created by Tony Smith on 29/08/2023. 7 | * Copyright © 2025 Tony Smith. All rights reserved. 8 | */ 9 | 10 | 11 | import Foundation 12 | import AppKit 13 | 14 | 15 | final class Common: NSObject { 16 | 17 | // MARK: - Definitions 18 | 19 | // String attribute categories 20 | enum AttributeType { 21 | case Key 22 | case Scalar 23 | case String 24 | case Special 25 | case Debug 26 | case MarkStart 27 | case MarkEnd 28 | } 29 | 30 | 31 | // MARK: - Public Properties 32 | 33 | var doShowLightBackground: Bool = false 34 | // FROM 1.1.0 35 | var doUseSpecialIndentChar: Bool = false 36 | 37 | 38 | // MARK: - Private Properties 39 | 40 | private var doShowRawJson: Bool = false 41 | private var doShowFurniture: Bool = true 42 | private var isThumbnail:Bool = false 43 | private var jsonIndent: Int = BUFFOON_CONSTANTS.JSON_INDENT 44 | private var boolStyle: Int = BUFFOON_CONSTANTS.BOOL_STYLE.FULL 45 | private var maxKeyLengths: [Int] = [0,0,0,0,0,0,0,0,0,0,0,0] 46 | private var fontSize: CGFloat = 0 47 | // String artifacts... 48 | private var hr: NSAttributedString = NSAttributedString.init(string: "") 49 | private var cr: NSAttributedString = NSAttributedString.init(string: "") 50 | // FROM 1.0.2 51 | private var lineCount: Int = 0 52 | // FROM 1.1.0 53 | private var sortKeys: Bool = true 54 | private var spacer: String = " " 55 | private var displayColours: [String:String] = [:] 56 | // FROM 1.1.1 57 | private var debugSpacer: String = "." 58 | 59 | // JSON string attributes... 60 | private var keyAttributes: [NSAttributedString.Key: Any] = [:] 61 | private var scalarAttributes: [NSAttributedString.Key: Any] = [:] 62 | private var markStartAttributes: [NSAttributedString.Key: Any] = [:] 63 | private var markEndAttributes: [NSAttributedString.Key: Any] = [:] 64 | // FROM 1.1.0 65 | private var stringAttributes: [NSAttributedString.Key: Any] = [:] 66 | private var specialAttributes: [NSAttributedString.Key: Any] = [:] 67 | // FROM 1.1.1 68 | private var debugAttributes: [NSAttributedString.Key: Any] = [:] 69 | 70 | /* 71 | Replace the following string with your own team ID. This is used to 72 | identify the app suite and so share preferences set by the main app with 73 | the previewer and thumbnailer extensions. 74 | */ 75 | private var appSuiteName: String = MNU_SECRETS.PID + BUFFOON_CONSTANTS.SUITE_NAME 76 | 77 | 78 | // MARK: - Lifecycle Functions 79 | 80 | init(_ isThumbnail: Bool = false) { 81 | 82 | super.init() 83 | 84 | self.fontSize = CGFloat(BUFFOON_CONSTANTS.BASE_PREVIEW_FONT_SIZE) 85 | var fontName: String = BUFFOON_CONSTANTS.BODY_FONT_NAME 86 | var keyColour: String = BUFFOON_CONSTANTS.KEY_COLOUR_HEX 87 | var markColour: String = BUFFOON_CONSTANTS.MARK_COLOUR_HEX 88 | var stringColour: String = BUFFOON_CONSTANTS.STRING_COLOUR_HEX 89 | var specialColour: String = BUFFOON_CONSTANTS.SPECIAL_COLOUR_HEX 90 | 91 | self.isThumbnail = isThumbnail 92 | 93 | // The suite name is the app group name, set in each extension's entitlements, and the host app's 94 | if let prefs = UserDefaults(suiteName: appSuiteName) { 95 | self.doShowFurniture = prefs.bool(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.SCALARS) 96 | self.doShowRawJson = prefs.bool(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BAD) 97 | self.doShowLightBackground = prefs.bool(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.USE_LIGHT) 98 | self.jsonIndent = isThumbnail ? 2 : prefs.integer(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.INDENT) 99 | self.boolStyle = isThumbnail ? BUFFOON_CONSTANTS.BOOL_STYLE.TEXT : prefs.integer(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BOOL_STYLE) 100 | 101 | self.fontSize = CGFloat(isThumbnail 102 | ? BUFFOON_CONSTANTS.BASE_THUMB_FONT_SIZE 103 | : prefs.float(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BODY_SIZE)) 104 | 105 | fontName = prefs.string(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BODY_FONT) ?? BUFFOON_CONSTANTS.BODY_FONT_NAME 106 | keyColour = prefs.string(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.KEY_COLOUR) ?? BUFFOON_CONSTANTS.KEY_COLOUR_HEX 107 | markColour = prefs.string(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.MARK_COLOUR) ?? BUFFOON_CONSTANTS.MARK_COLOUR_HEX 108 | // FROM 1.1.0 109 | stringColour = prefs.string(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.STRING_COLOUR) ?? BUFFOON_CONSTANTS.STRING_COLOUR_HEX 110 | specialColour = prefs.string(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.SPECIAL_COLOUR) ?? BUFFOON_CONSTANTS.SPECIAL_COLOUR_HEX 111 | } 112 | 113 | // Just in case the above block reads in zero values 114 | // NOTE The other values CAN be zero 115 | if self.fontSize < CGFloat(BUFFOON_CONSTANTS.FONT_SIZE_OPTIONS[0]) || 116 | self.fontSize > CGFloat(BUFFOON_CONSTANTS.FONT_SIZE_OPTIONS[BUFFOON_CONSTANTS.FONT_SIZE_OPTIONS.count - 1]) { 117 | self.fontSize = CGFloat(isThumbnail ? BUFFOON_CONSTANTS.BASE_THUMB_FONT_SIZE : BUFFOON_CONSTANTS.BASE_PREVIEW_FONT_SIZE) 118 | } 119 | 120 | // Set the JSON key:value fonts and sizes 121 | var font: NSFont 122 | if let chosenFont: NSFont = NSFont.init(name: fontName, size: self.fontSize) { 123 | font = chosenFont 124 | } else { 125 | font = NSFont.systemFont(ofSize: self.fontSize) 126 | } 127 | 128 | // Use a light theme? 129 | let useLightMode: Bool = isThumbnail || self.doShowLightBackground || isMacInLightMode() 130 | 131 | // Set up the attributed string components we may use during rendering 132 | self.keyAttributes = [ 133 | .foregroundColor: NSColor.hexToColour(keyColour), 134 | .font: font 135 | ] 136 | 137 | self.scalarAttributes = [ 138 | .foregroundColor: (useLightMode ? NSColor.black : NSColor.labelColor), 139 | .font: font 140 | ] 141 | 142 | self.markStartAttributes = [ 143 | .foregroundColor: NSColor.hexToColour(markColour), 144 | .font: font 145 | ] 146 | 147 | let endMarkParaStyle: NSMutableParagraphStyle = NSMutableParagraphStyle.init() 148 | endMarkParaStyle.paragraphSpacing = self.fontSize * 0.85 149 | self.markEndAttributes = [ 150 | .foregroundColor: NSColor.hexToColour(markColour), 151 | .font: font, 152 | .paragraphStyle: endMarkParaStyle 153 | ] 154 | 155 | // NOTE This no longer provides a full-width rule -- seek a fix 156 | self.hr = NSAttributedString(string: "\n\u{00A0}\u{0009}\u{00A0}\n\n", 157 | attributes: [.strikethroughStyle: NSUnderlineStyle.thick.rawValue, 158 | .strikethroughColor: (useLightMode ? NSColor.black : NSColor.white)]) 159 | 160 | self.cr = NSAttributedString.init(string: "\n", 161 | attributes: scalarAttributes) 162 | 163 | // FRON 1.1.0 164 | self.stringAttributes = [ 165 | .foregroundColor: NSColor.hexToColour(stringColour), 166 | .font: font 167 | ] 168 | 169 | self.specialAttributes = [ 170 | .foregroundColor: NSColor.hexToColour(specialColour), 171 | .font: font 172 | ] 173 | 174 | #if DEBUG 175 | self.debugAttributes = [ 176 | .foregroundColor: NSColor.hexToColour("444444FF"), 177 | .font: font 178 | ] 179 | #endif 180 | } 181 | 182 | 183 | /** 184 | Update certain style variables on a UI mode switch. 185 | FROM 1.1.0 186 | 187 | This is used by render demo app. 188 | */ 189 | func resetStylesOnModeChange() { 190 | 191 | // Set up the attributed string components we may use during rendering 192 | self.hr = NSAttributedString(string: "\n\u{00A0}\u{0009}\u{00A0}\n\n", 193 | attributes: [.strikethroughStyle: NSUnderlineStyle.thick.rawValue, 194 | .strikethroughColor: self.doShowLightBackground ? NSColor.black : NSColor.white]) 195 | 196 | self.scalarAttributes[.foregroundColor] = self.doShowLightBackground ? NSColor.black : NSColor.labelColor 197 | self.spacer = self.doUseSpecialIndentChar ? "-" : " " 198 | } 199 | 200 | 201 | // MARK: - The Primary Function 202 | 203 | /** 204 | Render the input JSON as an NSAttributedString. 205 | 206 | - Parameters: 207 | - jsonFileData: The path to the JSON code. 208 | 209 | - Returns: The rendered source as an NSAttributedString. 210 | */ 211 | func getAttributedString(_ jsonFileData: Data) -> NSAttributedString { 212 | 213 | var renderedString: NSMutableAttributedString 214 | 215 | do { 216 | // Attempt to parse the JSON data. First, get the data 217 | let json: Any = try JSONSerialization.jsonObject(with: jsonFileData, options: [ .fragmentsAllowed ]) 218 | 219 | // Calculate column widths based on key sizes 220 | // FROM 1.1.1 -- Check indent setting 221 | if (self.jsonIndent == BUFFOON_CONSTANTS.TABULATION_INDENT_VALUE && !self.isThumbnail) { 222 | assembleColumns(json) 223 | renderedString = tabulate(json) 224 | } else { 225 | renderedString = prettify(json) 226 | } 227 | 228 | // Just in case... 229 | if renderedString.length == 0 { 230 | renderedString = NSMutableAttributedString.init(string: "Could not render the JSON.\n\(json)\n", 231 | attributes: self.keyAttributes) 232 | } 233 | } catch { 234 | // No JSON to render, or the JSON was mis-formatted 235 | // Assemble the error string 236 | let errorString: NSMutableAttributedString = NSMutableAttributedString.init(string: "Could not render the JSON. ", 237 | attributes: self.keyAttributes) 238 | 239 | // Should we include the raw Json? 240 | // At least the user can see the data this way 241 | if self.doShowRawJson { 242 | errorString.append(NSMutableAttributedString.init(string: "Here is its raw form:", 243 | attributes: self.keyAttributes)) 244 | errorString.append(self.hr) 245 | 246 | let encoding: String.Encoding = jsonFileData.stringEncoding ?? .utf8 247 | 248 | if let jsonFileString: String = String.init(data: jsonFileData, encoding: encoding) { 249 | errorString.append(NSMutableAttributedString.init(string: "\(jsonFileString)\n", 250 | attributes: self.scalarAttributes)) 251 | } else { 252 | errorString.append(NSMutableAttributedString.init(string: "Sorry, this JSON file uses an unsupported coding: \(encoding)\n", 253 | attributes: self.scalarAttributes)) 254 | } 255 | } 256 | 257 | renderedString = errorString 258 | } 259 | 260 | return renderedString as NSAttributedString 261 | } 262 | 263 | 264 | /** REMOVED 1.1.0 265 | Return a space-prefix NSAttributedString. 266 | 267 | - Parameters: 268 | - baseString: The string to be indented. 269 | - indent: The number of indent spaces to add. 270 | - isKey: Are we rendering an inset key (`true`) or value (`false`). 271 | 272 | - Returns: The indented string as an NSAttributedString. 273 | 274 | func getIndentedString(_ baseString: String, _ indent: Int = 0, _ itemType: Int = BUFFOON_CONSTANTS.ITEM_TYPE.VALUE) -> NSAttributedString { 275 | 276 | let trimmedString = baseString.trimmingCharacters(in: .whitespaces) 277 | let spaceString = String(repeating: self.spacer, count: indent) 278 | 279 | var attributes: [NSAttributedString.Key: Any] 280 | switch itemType { 281 | case BUFFOON_CONSTANTS.ITEM_TYPE.KEY: 282 | attributes = self.keyAttributes 283 | case BUFFOON_CONSTANTS.ITEM_TYPE.MARK_START: 284 | attributes = self.markStartAttributes 285 | case BUFFOON_CONSTANTS.ITEM_TYPE.MARK_END: 286 | attributes = self.markEndAttributes 287 | 288 | default: 289 | attributes = self.scalarAttributes 290 | } 291 | 292 | let indentedString: NSMutableAttributedString = NSMutableAttributedString.init() 293 | indentedString.append(NSAttributedString.init(string: spaceString, attributes: self.scalarAttributes)) 294 | indentedString.append(NSAttributedString.init(string: trimmedString, attributes: attributes)) 295 | return indentedString.attributedSubstring(from: NSMakeRange(0, indentedString.length)) 296 | } 297 | */ 298 | 299 | 300 | /** 301 | Return a space-prefix NSAttributedString. 302 | FROM 1.1.0 303 | 304 | - Parameters: 305 | - baseString: The string to be indented. 306 | - indent: The number of indent spaces to add. 307 | - attributeType: The attribute to apply. 308 | 309 | - Returns: The indented string as an NSAttributedString. 310 | */ 311 | private func getIndentedAttributedString(_ baseString: Substring, _ indent: Int, _ attributeType: AttributeType) -> NSAttributedString { 312 | 313 | let indentedString: NSMutableAttributedString = NSMutableAttributedString.init() 314 | #if DEBUG 315 | let spaceString = indent > 0 ? String(repeating: self.debugSpacer, count: indent) : "" 316 | indentedString.append(NSAttributedString.init(string: spaceString, attributes: getAttributes(.Debug))) 317 | #else 318 | let spaceString = indent > 0 ? String(repeating: self.spacer, count: indent) : "" 319 | indentedString.append(NSAttributedString.init(string: spaceString, attributes: getAttributes(.Scalar))) 320 | #endif 321 | let trimmedString = baseString.trimmingCharacters(in: .whitespaces) 322 | indentedString.append(NSAttributedString.init(string: trimmedString, attributes: getAttributes(attributeType))) 323 | return indentedString.attributedSubstring(from: NSMakeRange(0, indentedString.length)) 324 | } 325 | 326 | 327 | /** 328 | Return an attribute dictionary from a passed attribute type. 329 | FROM 1.1.0 330 | 331 | - Parameters: 332 | - attributeType: The requested attribute type. 333 | 334 | - Returns: The attributes as a dictionary. 335 | */ 336 | private func getAttributes(_ attributeType: AttributeType) -> [NSAttributedString.Key: Any] { 337 | 338 | switch attributeType { 339 | case .Key: 340 | return self.keyAttributes 341 | case .MarkStart: 342 | return self.markStartAttributes 343 | case .MarkEnd: 344 | return self.markEndAttributes 345 | case .String: 346 | return self.stringAttributes 347 | case .Special: 348 | return self.specialAttributes 349 | case .Debug: 350 | return self.debugAttributes 351 | default: 352 | return self.scalarAttributes 353 | } 354 | } 355 | 356 | 357 | /** 358 | Return a space-prefix NSAttributedString formed from an image in the app Bundle 359 | 360 | - Parameters: 361 | - indent: The number of indent spaces to add. 362 | - imageName: The name of the image to load and insert. 363 | 364 | - Returns: The indented string as an optional NSAttributedString. Nil indicates an error 365 | */ 366 | private func getImageString(_ indent: Int = 1, _ imageName: String) -> NSAttributedString? { 367 | 368 | let insetImage: NSTextAttachment = NSTextAttachment() 369 | insetImage.image = NSImage(named: imageName) 370 | if insetImage.image != nil { 371 | insetImage.image!.size = NSMakeSize(insetImage.image!.size.width * self.fontSize / insetImage.image!.size.height, self.fontSize) 372 | let imageString: NSAttributedString = NSAttributedString(attachment: insetImage) 373 | let spaceString = String(repeating: self.spacer, count: indent) 374 | let indentedString: NSMutableAttributedString = NSMutableAttributedString.init() 375 | indentedString.append(NSAttributedString.init(string: spaceString, attributes: self.scalarAttributes)) 376 | indentedString.append(imageString) 377 | indentedString.append(self.cr) 378 | return indentedString 379 | } 380 | 381 | return nil 382 | } 383 | 384 | 385 | /** 386 | Render a unit of JSON as an NSAttributedString. 387 | 388 | - Parameters: 389 | - json: A unit of JSON, type Any. 390 | - currentLevel: The current element depth. 391 | - currentIndent: How much a subsequent value should be indented. 392 | - parentIsObject: If and only if the parent is an object. 393 | 394 | - Returns: The indented string as an NSAttributedString. 395 | */ 396 | private func prettify(_ json: Any, _ currentLevel: Int = 0, _ currentIndent: Int = 0, _ parentIsObject: Bool = false) -> NSMutableAttributedString { 397 | 398 | // Prep an NSMutableAttributedString for this JSON segment 399 | let renderedString: NSMutableAttributedString = NSMutableAttributedString.init(string: "", attributes: self.keyAttributes) 400 | 401 | // FROM 1.0.2 402 | // Break early at a sensible location, ie. one that 403 | // leaves us with a valid subset of the source JSON 404 | // NOTE This can be done better with checks on the returned string 405 | self.lineCount += 1; 406 | if self.isThumbnail && (self.lineCount > BUFFOON_CONSTANTS.THUMBNAIL_LINE_COUNT) { 407 | return renderedString 408 | } 409 | 410 | // Set the indent based on the current level 411 | // This will be used for rendering keys and calculating 412 | // next-level indents. It's just a multiple of the level 413 | var indent: Int = currentIndent 414 | if self.isThumbnail { 415 | indent = currentLevel * BUFFOON_CONSTANTS.BASE_INDENT 416 | } 417 | 418 | // Generate a string according to the JSON element's underlying type 419 | // FROM 1.0.4 Booleans are 'Bool' and 'Int', so remove the old bool check 420 | // and rely on the earlier string encoding in PreviewViewController 421 | if json is NSNull { 422 | // Attempt to load the null symbol, but use a text version as a fallback on error 423 | if self.boolStyle != BUFFOON_CONSTANTS.BOOL_STYLE.TEXT { 424 | // Display NULL as an image 425 | let name: String = "null_\(self.boolStyle)" 426 | if !self.isThumbnail, let addString: NSAttributedString = getImageString(indent, name) { 427 | renderedString.append(addString) 428 | return renderedString 429 | } 430 | } 431 | 432 | // Can't or won't show an image? Show text 433 | renderedString.append(getIndentedAttributedString("NULL\n", indent, .Special)) 434 | } else if json is Int || json is Float || json is Double { 435 | // Display the number as is 436 | renderedString.append(getIndentedAttributedString("\(json)\n", indent, .Scalar)) 437 | } else if json is String { 438 | let value: String = json as! String 439 | 440 | // Is this a shimmed boolean? 441 | if value == "PREVIEW-JSON-TRUE" || value == "PREVIEW-JSON-FALSE" { 442 | if self.boolStyle != BUFFOON_CONSTANTS.BOOL_STYLE.TEXT { 443 | // Render the bool as an image 444 | let name: String = value == "PREVIEW-JSON-TRUE" ? "true_\(self.boolStyle)" : "false_\(self.boolStyle)" 445 | if !self.isThumbnail, let addString: NSAttributedString = getImageString(indent, name) { 446 | renderedString.append(addString) 447 | return renderedString 448 | } 449 | } 450 | 451 | // Can't or won't show an image? Show text 452 | renderedString.append(getIndentedAttributedString(value == "PREVIEW-JSON-TRUE" ? "TRUE\n" : "FALSE\n", indent, .Special)) 453 | } else { 454 | // Regular string value; add quotes if necessary 455 | let stringText: String = self.doShowFurniture ? "“" + (json as! String) + "”\n" : (json as! String) + "\n" 456 | renderedString.append(getIndentedAttributedString(stringText[...], indent, .String)) 457 | } 458 | } else if json is Dictionary { 459 | // For a dictionary, enumerate the key and value 460 | // NOTE Should be only one of each, but value may 461 | // be an object or array 462 | 463 | if self.doShowFurniture { 464 | // Add JSON furniture 465 | // If the parent is an object too, don't indent (we have already indented) 466 | let initialFurnitureIndent: Int = parentIsObject ? 0 : indent 467 | renderedString.append(getIndentedAttributedString("{\n", initialFurnitureIndent, .MarkStart)) 468 | } 469 | 470 | let anyObject: [String: Any] = json as! [String: Any] 471 | 472 | // FROM 1.1.0 -- sort dictionaries alphabetically by key 473 | var keys: [String] = Array(anyObject.keys) 474 | if self.sortKeys { 475 | keys = keys.sorted(by: { (a, b) -> Bool in 476 | return (a.lowercased() < b.lowercased()) 477 | }) 478 | } 479 | 480 | // Set the indent of each key 481 | // The base indent or, if we're showing furniture, base + the specific indent value 482 | let keyIndent: Int = self.doShowFurniture ? indent + self.jsonIndent : indent 483 | 484 | for key in keys { 485 | // Get important value types 486 | let value: Any = anyObject[key]! 487 | let valueIsObject: Bool = (value is Dictionary) 488 | let valueIsArray: Bool = (value is Array) 489 | 490 | // Print the key 491 | renderedString.append(getIndentedAttributedString(key[...], keyIndent, .Key)) 492 | 493 | // Print a trailing space after the key 494 | renderedString.append(NSAttributedString.init(string: " ", attributes: getAttributes(.Key))) 495 | 496 | // Now for the value: is it non-scalar? 497 | if valueIsObject || valueIsArray { 498 | // Render the element at the next level. When we're showing furniture, the ident is the same as the key, 499 | // because of how keyIndent is set, otherwise we need to add the standard indent 500 | let valueIndent: Int = self.doShowFurniture ? keyIndent : keyIndent + self.jsonIndent 501 | 502 | // Add a carriage return to space out objects 503 | // when we're not showing furniture 504 | if !self.doShowFurniture { 505 | renderedString.append(self.cr) 506 | } 507 | 508 | renderedString.append(prettify(value, 509 | currentLevel + 1, // Next level to key 510 | valueIndent, // This level's base indent 511 | true)) 512 | } else { 513 | // Render the scalar value immediately after the key 514 | // NOTE The key has a trailing space, so no extra indent is required for the scalar value 515 | renderedString.append(prettify(value, 516 | currentLevel, // Same level as key 517 | 0)) // No indent 518 | } 519 | } 520 | 521 | if self.doShowFurniture { 522 | // Bookend with JSON furniture 523 | renderedString.append(getIndentedAttributedString("}\n", indent, .MarkEnd)) 524 | } 525 | } else if json is Array { 526 | if self.doShowFurniture { 527 | // Add JSON furniture 528 | // If parent is an object, add furniture right after key, otherwise indent 529 | let initialIndent: Int = parentIsObject ? 0 : indent 530 | renderedString.append(getIndentedAttributedString("[\n", initialIndent, .MarkStart)) 531 | } 532 | 533 | // Iterate over the array's items 534 | // Array items are always rendered at the same level 535 | let anyArray: [Any] = json as! [Any] 536 | let valueIndent: Int = self.doShowFurniture ? indent + self.jsonIndent : indent 537 | var count: Int = 0 538 | 539 | anyArray.forEach { value in 540 | // Get important value types 541 | let valueIsObject: Bool = (value is Dictionary) 542 | let valueIsArray: Bool = (value is Array) 543 | 544 | // Is the value non-scalar? 545 | if valueIsObject || valueIsArray { 546 | // Render the element on the next level 547 | renderedString.append(prettify(value, 548 | currentLevel + 1, 549 | valueIndent)) 550 | 551 | // Separate all but the last item with a blank line 552 | if count < anyArray.count - 1 && !self.doShowFurniture { 553 | renderedString.append(self.cr) 554 | } 555 | } else { 556 | // Render the scalar value 557 | renderedString.append(prettify(value, 558 | currentLevel, 559 | valueIndent)) 560 | } 561 | 562 | count += 1 563 | } 564 | 565 | // Bookend with JSON furniture 566 | if self.doShowFurniture { 567 | renderedString.append(getIndentedAttributedString("]\n", indent, .MarkEnd)) 568 | } 569 | } 570 | 571 | return renderedString 572 | } 573 | 574 | 575 | /** 576 | Render a unit of JSON as an NSAttributedString using Tabulation. 577 | FROM 1.1.1 578 | 579 | - Parameters: 580 | - json: A unit of JSON, type Any. 581 | - currentLevel: The current element depth. 582 | - currentIndent: How much a subsequent value should be indented. 583 | - parentIsObject: If and only if the parent is an object. 584 | 585 | - Returns: The indented string as an NSAttributedString. 586 | */ 587 | private func tabulate(_ json: Any, _ currentLevel: Int = 0, _ currentIndent: Int = 0, _ parentIsObject: Bool = false) -> NSMutableAttributedString { 588 | 589 | // Prep an NSMutableAttributedString for this JSON segment 590 | let renderedString: NSMutableAttributedString = NSMutableAttributedString.init(string: "", attributes: self.keyAttributes) 591 | 592 | // Generate a string according to the JSON element's underlying type 593 | // Booleans are 'Bool' and 'Int', so remove the old bool check 594 | // and rely on the earlier string encoding in PreviewViewController 595 | if json is NSNull { 596 | // Attempt to load the null symbol, but use a text version as a fallback on error 597 | if self.boolStyle != BUFFOON_CONSTANTS.BOOL_STYLE.TEXT { 598 | // Display NULL as an image 599 | let name: String = "null_\(self.boolStyle)" 600 | if !self.isThumbnail, let addString: NSAttributedString = getImageString(currentIndent, name) { 601 | renderedString.append(addString) 602 | return renderedString 603 | } 604 | } 605 | 606 | // Can't or won't show an image? Show text 607 | #if DEBUG 608 | renderedString.append(getIndentedAttributedString("NULL", currentIndent, .Special)) 609 | renderedString.append(getIndentedAttributedString("\(currentLevel)/\(currentIndent)\n", 1, .Debug)) 610 | #else 611 | renderedString.append(getIndentedAttributedString("NULL\n", currentIndent, .Special)) 612 | #endif 613 | } else if json is Int || json is Float || json is Double { 614 | // Display the number as is 615 | #if DEBUG2 616 | renderedString.append(getIndentedAttributedString("\(json)", currentIndent, .Scalar)) 617 | renderedString.append(getIndentedAttributedString(" \(currentLevel)/\(currentIndent)\n", 1, .Debug)) 618 | #else 619 | renderedString.append(getIndentedAttributedString("\(json)\n", currentIndent, .Scalar)) 620 | #endif 621 | } else if json is String { 622 | let value: String = json as! String 623 | 624 | // Is this a shimmed boolean? 625 | if value == "PREVIEW-JSON-TRUE" || value == "PREVIEW-JSON-FALSE" { 626 | if self.boolStyle != BUFFOON_CONSTANTS.BOOL_STYLE.TEXT { 627 | // Render the bool as an image 628 | let name: String = value == "PREVIEW-JSON-TRUE" ? "true_\(self.boolStyle)" : "false_\(self.boolStyle)" 629 | if !self.isThumbnail, let addString: NSAttributedString = getImageString(currentIndent, name) { 630 | renderedString.append(addString) 631 | return renderedString 632 | } 633 | } 634 | 635 | // Can't or won't show an image? Show text 636 | #if DEBUG2 637 | renderedString.append(getIndentedAttributedString(value == "PREVIEW-JSON-TRUE" ? "TRUE" : "FALSE", currentIndent, .Special)) 638 | renderedString.append(getIndentedAttributedString("\(currentLevel)/\(currentIndent)\n", 1, .Debug)) 639 | #else 640 | renderedString.append(getIndentedAttributedString(value == "PREVIEW-JSON-TRUE" ? "TRUE\n" : "FALSE\n", currentIndent, .Special)) 641 | #endif 642 | } else { 643 | // Regular string value; add quotes if necessary 644 | #if DEBUG2 645 | let stringText: String = self.doShowFurniture ? "“" + (json as! String) + "”" : (json as! String) 646 | renderedString.append(getIndentedAttributedString(stringText[...], currentIndent, .String)) 647 | renderedString.append(getIndentedAttributedString("\(currentLevel)/\(currentIndent)\n", 1, .Debug)) 648 | #else 649 | let stringText: String = self.doShowFurniture ? "“" + (json as! String) + "”\n" : (json as! String) //+ "\n" 650 | renderedString.append(getIndentedAttributedString(stringText[...], currentIndent, .String)) 651 | #endif 652 | } 653 | } else if json is Dictionary { 654 | // For a dictionary, enumerate the key and value 655 | // NOTE Should be only one of each, but value may 656 | // be an object or array 657 | 658 | if self.doShowFurniture { 659 | // Add JSON furniture 660 | // If the parent is an object too, don't indent (we have already indented) 661 | let initialIndent: Int = parentIsObject ? 0 : currentIndent 662 | #if DEBUG2 663 | renderedString.append(getIndentedAttributedString("{", initialIndent, .MarkStart)) 664 | renderedString.append(getIndentedAttributedString("\(currentLevel)/\(currentIndent)/\(initialIndent)\n", 1, .Debug)) 665 | #else 666 | renderedString.append(getIndentedAttributedString("{\n", initialIndent, .MarkStart)) 667 | #endif 668 | } 669 | 670 | let anyObject: [String: Any] = json as! [String: Any] 671 | 672 | // FROM 1.1.0 -- sort dictionaries alphabetically by key 673 | var keys: [String] = Array(anyObject.keys) 674 | if self.sortKeys { 675 | keys = keys.sorted(by: { (a, b) -> Bool in 676 | return (a.lowercased() < b.lowercased()) 677 | }) 678 | } 679 | 680 | // Indent slightly 681 | let keyIndent: Int = self.doShowFurniture ? currentIndent + BUFFOON_CONSTANTS.TABBED_INDENT : currentIndent 682 | for key in keys { 683 | // Get important value types 684 | let value: Any = anyObject[key]! 685 | let valueIsObject: Bool = (value is Dictionary) 686 | let valueIsArray: Bool = (value is Array) 687 | 688 | // Print the key 689 | renderedString.append(getIndentedAttributedString(key[...], keyIndent, .Key)) 690 | // Space after 691 | renderedString.append(NSAttributedString.init(string: String(repeating: self.spacer, count: self.maxKeyLengths[currentLevel] - key.count + 1), attributes: getAttributes(.Key))) 692 | 693 | // Is the value non-scalar? 694 | if valueIsObject || valueIsArray { 695 | // Render the element at the next level 696 | let nextIndent: Int = keyIndent + self.maxKeyLengths[currentLevel] + 1 697 | if !self.doShowFurniture { 698 | renderedString.append(self.cr) 699 | } 700 | 701 | renderedString.append(tabulate(value, 702 | currentLevel + (valueIsObject ? 1 : 0), // Next level 703 | nextIndent, // This level's base indent 704 | true)) 705 | } else { 706 | renderedString.append(tabulate(value, 707 | currentLevel, // Same level 708 | 0)) 709 | } 710 | } 711 | 712 | if self.doShowFurniture { 713 | // Bookend with JSON furniture 714 | #if DEBUG2 715 | renderedString.append(getIndentedAttributedString("}", currentIndent, .MarkEnd)) 716 | renderedString.append(getIndentedAttributedString("\(currentLevel)/\(currentIndent)\n", 1, .Debug)) 717 | #else 718 | renderedString.append(getIndentedAttributedString("}\n", currentIndent, .MarkEnd)) 719 | #endif 720 | } 721 | } else if json is Array { 722 | if self.doShowFurniture { 723 | // Add JSON furniture 724 | // NOTE Parent is an object, so add furniture after key 725 | let initialIndent: Int = parentIsObject ? 0 : currentIndent 726 | 727 | #if DEBUG2 728 | renderedString.append(getIndentedAttributedString("[", initialIndent, .MarkStart)) 729 | renderedString.append(getIndentedAttributedString("\(currentLevel)/\(currentIndent)/\(initialIndent)\n", 1, .Debug)) 730 | #else 731 | renderedString.append(getIndentedAttributedString("[\n", initialIndent, .MarkStart)) 732 | #endif 733 | } 734 | 735 | // Iterate over the array's items 736 | // Array items are always rendered at the same level 737 | let anyArray: [Any] = json as! [Any] 738 | var count: Int = 0 739 | anyArray.forEach { value in 740 | // Get important value types 741 | let valueIsObject: Bool = (value is Dictionary) 742 | let valueIsArray: Bool = (value is Array) 743 | let nextIndent: Int = self.doShowFurniture ? currentIndent + BUFFOON_CONSTANTS.TABBED_INDENT : currentIndent 744 | 745 | // Is the value non-scalar? 746 | if valueIsObject || valueIsArray { 747 | // Render the element on the next level 748 | renderedString.append(tabulate(value, 749 | currentLevel + (valueIsObject ? 1 : 0), 750 | nextIndent)) 751 | 752 | // Separate all but the last item with a blank line 753 | if count < anyArray.count - 1 && !self.doShowFurniture { 754 | renderedString.append(self.cr) 755 | } 756 | } else { 757 | // Render the scalar value 758 | renderedString.append(tabulate(value, 759 | 0, 760 | nextIndent)) 761 | } 762 | 763 | count += 1 764 | } 765 | 766 | if self.doShowFurniture { 767 | // Bookend with JSON furniture 768 | #if DEBUG2 769 | renderedString.append(getIndentedAttributedString("]", currentIndent, .MarkEnd)) 770 | renderedString.append(getIndentedAttributedString("\(currentLevel)/\(currentIndent)\n", 1, .Debug)) 771 | #else 772 | renderedString.append(getIndentedAttributedString("]\n", currentIndent, .MarkEnd)) 773 | #endif 774 | } 775 | } 776 | 777 | return renderedString 778 | } 779 | 780 | 781 | /** 782 | Iterate through a JSON element to caclulate the current max. key length. 783 | FROM 1.1.0 784 | 785 | - Parameters: 786 | - json: The JSON element. 787 | - level: The current level. 788 | */ 789 | private func assembleColumns(_ json: Any, _ level: Int = 0) { 790 | 791 | // FROM 1.1.1 792 | if level > (self.maxKeyLengths.count - 1) { 793 | let count = level - self.maxKeyLengths.count + 1 794 | for _ in 0...count { 795 | self.maxKeyLengths.append(0) 796 | } 797 | } 798 | 799 | if json is Dictionary { 800 | // For a dictionary, enumerate the key and value 801 | let anyObject: [String: Any] = json as! [String: Any] 802 | 803 | // Get the max key length for the current level 804 | let keys: [String] = Array(anyObject.keys) 805 | for key in keys { 806 | if key.count > self.maxKeyLengths[level] { 807 | self.maxKeyLengths[level] = key.count 808 | } 809 | } 810 | 811 | // Iterate through the keys to run this code for higher levels 812 | anyObject.forEach { key, value in 813 | // Check for non-scalar elements 814 | let valueIsObject: Bool = (value is Dictionary) 815 | let valueIsArray: Bool = (value is Array) 816 | 817 | if valueIsObject || valueIsArray { 818 | // Process object values on the next level, 819 | // but array values on the same level 820 | assembleColumns(value, level + (valueIsObject ? 1 : 0)) 821 | } 822 | } 823 | } else if json is Array { 824 | // For an array, enumerate the elements 825 | let anyArray: [Any] = json as! [Any] 826 | anyArray.forEach { value in 827 | let valueIsObject: Bool = value is Dictionary 828 | let valueIsArray: Bool = value is Array 829 | 830 | if valueIsObject || valueIsArray { 831 | // Process container - objects or array - values on the next level 832 | assembleColumns(value, level + (valueIsObject ? 1 : 0)) 833 | } 834 | } 835 | } 836 | } 837 | 838 | 839 | /** 840 | Determine whether the host Mac is in light mode. 841 | FROM 1.1.0 842 | 843 | - Returns: `true` if the Mac is in light mode, otherwise `false`. 844 | */ 845 | private func isMacInLightMode() -> Bool { 846 | 847 | let appearNameString: String = NSApp.effectiveAppearance.name.rawValue 848 | return (appearNameString == "NSAppearanceNameAqua") 849 | } 850 | 851 | } 852 | 853 | 854 | /** 855 | Get the encoding of the string formed from data. 856 | 857 | - Returns: The string's encoding or nil. 858 | */ 859 | 860 | extension Data { 861 | 862 | var stringEncoding: String.Encoding? { 863 | guard case let rawValue = NSString.stringEncoding(for: self, 864 | encodingOptions: nil, 865 | convertedString: nil, 866 | usedLossyConversion: nil), rawValue != 0 else { return nil } 867 | return .init(rawValue: rawValue) 868 | } 869 | } 870 | 871 | 872 | -------------------------------------------------------------------------------- /PreviewJson/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * AppDelegate.swift 3 | * PreviewJson 4 | * 5 | * Created by Tony Smith on 9/08/2023. 6 | * Copyright © 2025 Tony Smith. All rights reserved. 7 | */ 8 | 9 | 10 | import Cocoa 11 | import CoreServices 12 | import WebKit 13 | 14 | 15 | @main 16 | final class AppDelegate: NSObject, 17 | NSApplicationDelegate, 18 | URLSessionDelegate, 19 | URLSessionDataDelegate, 20 | WKNavigationDelegate { 21 | 22 | // MARK: - Class UI Properies 23 | 24 | // Menu Items 25 | @IBOutlet weak var helpMenu: NSMenuItem! 26 | @IBOutlet weak var helpMenuOnlineHelp: NSMenuItem! 27 | @IBOutlet weak var helpMenuAppStoreRating: NSMenuItem! 28 | @IBOutlet weak var helpMenuOthersPreviewMarkdown: NSMenuItem! 29 | @IBOutlet weak var helpMenuOthersPreviewCode: NSMenuItem! 30 | //@IBOutlet weak var helpMenuOtherspreviewYaml: NSMenuItem! 31 | // FROM 1.0.3 32 | @IBOutlet weak var helpMenuWhatsNew: NSMenuItem! 33 | @IBOutlet weak var helpMenuReportBug: NSMenuItem! 34 | //@IBOutlet weak var helpMenuOthersPreviewText: NSMenuItem! 35 | @IBOutlet weak var mainMenuSettings: NSMenuItem! 36 | 37 | // Panel Items 38 | @IBOutlet weak var versionLabel: NSTextField! 39 | 40 | // Windows 41 | @IBOutlet weak var window: NSWindow! 42 | 43 | // Report Sheet 44 | @IBOutlet weak var reportWindow: NSWindow! 45 | @IBOutlet weak var feedbackText: NSTextField! 46 | @IBOutlet weak var connectionProgress: NSProgressIndicator! 47 | 48 | // Preferences Sheet 49 | @IBOutlet weak var preferencesWindow: NSWindow! 50 | @IBOutlet weak var fontSizeSlider: NSSlider! 51 | @IBOutlet weak var fontSizeLabel: NSTextField! 52 | @IBOutlet weak var codeFontPopup: NSPopUpButton! 53 | @IBOutlet weak var codeStylePopup: NSPopUpButton! 54 | @IBOutlet weak var codeIndentPopup: NSPopUpButton! 55 | @IBOutlet weak var codeColorWell: NSColorWell! 56 | //@IBOutlet weak var markColorWell: NSColorWell! 57 | @IBOutlet weak var boolStyleSegment: NSSegmentedControl! 58 | @IBOutlet weak var useLightCheckbox: NSButton! 59 | @IBOutlet weak var doShowRawJsonCheckbox: NSButton! 60 | @IBOutlet weak var doShowJsonFurnitureCheckbox: NSButton! 61 | // FROM 1.1.0 62 | @IBOutlet weak var colourSelectionPopup: NSPopUpButton! 63 | 64 | // What's New Sheet 65 | @IBOutlet weak var whatsNewWindow: NSWindow! 66 | @IBOutlet weak var whatsNewWebView: WKWebView! 67 | 68 | 69 | // MARK: - Private Properies 70 | 71 | internal var whatsNewNav: WKNavigation? = nil 72 | private var feedbackTask: URLSessionTask? = nil 73 | private var indentDepth: Int = BUFFOON_CONSTANTS.JSON_INDENT 74 | private var boolStyle: Int = BUFFOON_CONSTANTS.BOOL_STYLE.FULL 75 | private var fontSize: CGFloat = CGFloat(BUFFOON_CONSTANTS.BASE_PREVIEW_FONT_SIZE) 76 | private var fontName: String = BUFFOON_CONSTANTS.BODY_FONT_NAME 77 | //private var codeColourHex: String = BUFFOON_CONSTANTS.KEY_COLOUR_HEX 78 | //private var markColourHex: String = BUFFOON_CONSTANTS.MARK_COLOUR_HEX 79 | private var doShowLightBackground: Bool = false 80 | private var doShowTag: Bool = false 81 | private var doShowRawJson: Bool = false 82 | private var doShowFurniture: Bool = true 83 | internal var isMontereyPlus: Bool = false 84 | internal var codeFonts: [PMFont] = [] 85 | // FROM 1.0.3 86 | //private var havePrefsChanged: Bool = false 87 | // FROM 1.1.0 88 | private var displayColours: [String:String] = [:] 89 | 90 | /* 91 | Replace the following string with your own team ID. This is used to 92 | identify the app suite and so share preferences set by the main app with 93 | the previewer and thumbnailer extensions. 94 | */ 95 | private var appSuiteName: String = MNU_SECRETS.PID + BUFFOON_CONSTANTS.SUITE_NAME 96 | 97 | 98 | // MARK: - Class Lifecycle Functions 99 | 100 | func applicationDidFinishLaunching(_ notification: Notification) { 101 | 102 | // Asynchronously get the list of code fonts 103 | DispatchQueue.init(label: "com.bps.previewjson.async-queue").async { 104 | self.asyncGetFonts() 105 | } 106 | 107 | // Set application group-level defaults 108 | registerPreferences() 109 | recordSystemState() 110 | 111 | // Add the app's version number to the UI 112 | let version: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as! String 113 | let build: String = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as! String 114 | versionLabel.stringValue = "Version \(version) (\(build))" 115 | 116 | // Disable the Help menu Spotlight features 117 | let dummyHelpMenu: NSMenu = NSMenu.init(title: "Dummy") 118 | let theApp = NSApplication.shared 119 | theApp.helpMenu = dummyHelpMenu 120 | 121 | // Watch for macOS UI mode changes 122 | DistributedNotificationCenter.default.addObserver(self, 123 | selector: #selector(interfaceModeChanged), 124 | name: NSNotification.Name(rawValue: "AppleInterfaceThemeChangedNotification"), 125 | object: nil) 126 | 127 | // Centre the main window and display 128 | self.window.center() 129 | self.window.makeKeyAndOrderFront(self) 130 | 131 | // Show the 'What's New' panel if we need to 132 | // NOTE Has to take place at the end of the function 133 | doShowWhatsNew(self) 134 | } 135 | 136 | 137 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { 138 | 139 | // When the main window closed, shut down the app 140 | return true 141 | } 142 | 143 | 144 | // MARK: - Action Functions 145 | 146 | /** 147 | Called from **File > Close** and the various Quit controls. 148 | 149 | - Parameters: 150 | - sender: The source of the action. 151 | */ 152 | @IBAction private func doClose(_ sender: Any) { 153 | 154 | // Reset the QL thumbnail cache... just in case it helps 155 | _ = runProcess(app: "/usr/bin/qlmanage", with: ["-r", "cache"]) 156 | 157 | // FROM 1.0.3 158 | // Check for open panels 159 | if self.preferencesWindow.isVisible { 160 | if checkPrefs() { 161 | let alert: NSAlert = showAlert("You have unsaved settings", 162 | "Do you wish to cancel and save these, or quit the app anyway?", 163 | false) 164 | alert.addButton(withTitle: "Quit") 165 | alert.addButton(withTitle: "Cancel") 166 | alert.beginSheetModal(for: self.preferencesWindow) { (response: NSApplication.ModalResponse) in 167 | if response == NSApplication.ModalResponse.alertFirstButtonReturn { 168 | // The user clicked 'Quit' 169 | self.preferencesWindow.close() 170 | self.window.close() 171 | } 172 | } 173 | 174 | return 175 | } 176 | 177 | self.preferencesWindow.close() 178 | } 179 | 180 | if self.whatsNewWindow.isVisible { 181 | self.whatsNewWindow.close() 182 | } 183 | 184 | if self.reportWindow.isVisible { 185 | if self.feedbackText.stringValue.count > 0 { 186 | let alert: NSAlert = showAlert("You have unsent feedback", 187 | "Do you wish to cancel and send it, or quit the app anyway?", 188 | false) 189 | alert.addButton(withTitle: "Quit") 190 | alert.addButton(withTitle: "Cancel") 191 | alert.beginSheetModal(for: self.reportWindow) { (response: NSApplication.ModalResponse) in 192 | if response == NSApplication.ModalResponse.alertFirstButtonReturn { 193 | // The user clicked 'Quit' 194 | self.reportWindow.close() 195 | self.window.close() 196 | } 197 | } 198 | 199 | return 200 | } 201 | 202 | self.reportWindow.close() 203 | } 204 | 205 | // Close the window... which will trigger an app closure 206 | self.window.close() 207 | } 208 | 209 | 210 | /** 211 | Called from various **Help** items to open various websites. 212 | 213 | - Parameters: 214 | - sender: The source of the action. 215 | */ 216 | @IBAction @objc private func doShowSites(sender: Any) { 217 | 218 | // Open the websites for contributors, help and suc 219 | let item: NSMenuItem = sender as! NSMenuItem 220 | var path: String = BUFFOON_CONSTANTS.URL_MAIN 221 | 222 | // Depending on the menu selected, set the load path 223 | if item == self.helpMenuAppStoreRating { 224 | path = BUFFOON_CONSTANTS.APP_STORE + "?action=write-review" 225 | } else if item == self.helpMenuOnlineHelp { 226 | path += "#how-to-use-previewjson" 227 | } else if item == self.helpMenuOthersPreviewMarkdown { 228 | path = BUFFOON_CONSTANTS.APP_URLS.PM 229 | } else if item == self.helpMenuOthersPreviewCode { 230 | path = BUFFOON_CONSTANTS.APP_URLS.PC 231 | } //else if item == self.helpMenuOtherspreviewYaml { 232 | // path = BUFFOON_CONSTANTS.APP_URLS.PY 233 | //} else if item == self.helpMenuOthersPreviewText { 234 | // path = BUFFOON_CONSTANTS.APP_URLS.PT 235 | //} 236 | 237 | // Open the selected website 238 | NSWorkspace.shared.open(URL.init(string:path)!) 239 | } 240 | 241 | 242 | /** 243 | Open the System Preferences app at the Extensions pane. 244 | 245 | - Parameters: 246 | - sender: The source of the action. 247 | */ 248 | @IBAction private func doOpenSysPrefs(sender: Any) { 249 | 250 | // Open the System Preferences app at the Extensions pane 251 | NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Library/PreferencePanes/Extensions.prefPane")) 252 | } 253 | 254 | 255 | // MARK: - Report Functions 256 | 257 | /** 258 | Display a window in which the user can submit feedback, or report a bug. 259 | 260 | - Parameters: 261 | - sender: The source of the action. 262 | */ 263 | @IBAction @objc private func doShowReportWindow(sender: Any?) { 264 | 265 | // FROM 1.0.3 266 | // Hide menus we don't want used while panel is open 267 | hidePanelGenerators() 268 | 269 | // Reset the UI 270 | self.connectionProgress.stopAnimation(self) 271 | self.feedbackText.stringValue = "" 272 | 273 | // Present the window 274 | self.window.beginSheet(self.reportWindow, 275 | completionHandler: nil) 276 | } 277 | 278 | 279 | /** 280 | User has clicked the Report window's **Cancel** button, so just close the sheet. 281 | 282 | - Parameters: 283 | - sender: The source of the action. 284 | */ 285 | @IBAction @objc private func doCancelReportWindow(sender: Any) { 286 | 287 | // User has clicked the Report window's 'Cancel' button, 288 | // so just close the sheet 289 | 290 | self.connectionProgress.stopAnimation(self) 291 | self.window.endSheet(self.reportWindow) 292 | 293 | // FROM 1.0.3 294 | // Restore menus 295 | showPanelGenerators() 296 | } 297 | 298 | 299 | /** 300 | User has clicked the Report window's **Send** button. 301 | 302 | Get the message (if there is one) from the text field and submit it. 303 | 304 | - Parameters: 305 | - sender: The source of the action. 306 | */ 307 | @IBAction @objc private func doSendFeedback(sender: Any) { 308 | 309 | // User has clicked the Report window's 'Send' button, 310 | // so get the message (if there is one) from the text field and submit it 311 | 312 | let feedback: String = self.feedbackText.stringValue 313 | 314 | if feedback.count > 0 { 315 | // Start the connection indicator if it's not already visible 316 | self.connectionProgress.startAnimation(self) 317 | 318 | /* 319 | Add your own `func sendFeedback(_ feedback: String) -> URLSessionTask?` function 320 | */ 321 | self.feedbackTask = sendFeedback(feedback) 322 | 323 | if self.feedbackTask != nil { 324 | // We have a valid URL Session Task, so start it to send 325 | self.feedbackTask!.resume() 326 | return 327 | } else { 328 | // Report the error 329 | sendFeedbackError() 330 | } 331 | 332 | return 333 | } 334 | 335 | // No feedback, so close the sheet 336 | self.window.endSheet(self.reportWindow) 337 | 338 | // FROM 1.0.3 339 | // Restore menus 340 | showPanelGenerators() 341 | 342 | // NOTE sheet closes asynchronously unless there was no feedback to send, 343 | // or an error occured with setting up the feedback session 344 | } 345 | 346 | 347 | // MARK: - Preferences Functions 348 | 349 | /** 350 | Initialise and display the **Preferences** sheet. 351 | 352 | - Parameters: 353 | - sender: The source of the action. 354 | */ 355 | @IBAction private func doShowPreferences(sender: Any) { 356 | 357 | // FROM 1.0.3 358 | // Hide menus we don't want used while panel is open 359 | hidePanelGenerators() 360 | 361 | // FROM 1.0.3 362 | // Reset changes prefs flag 363 | //self.havePrefsChanged = false 364 | 365 | // The suite name is the app group name, set in each the entitlements file of 366 | // the host app and of each extension 367 | if let defaults = UserDefaults(suiteName: self.appSuiteName) { 368 | self.fontSize = CGFloat(defaults.float(forKey: "com-bps-previewjson-base-font-size")) 369 | self.fontName = defaults.string(forKey: "com-bps-previewjson-base-font-name") ?? BUFFOON_CONSTANTS.BODY_FONT_NAME 370 | self.indentDepth = defaults.integer(forKey: "com-bps-previewjson-json-indent") 371 | self.doShowLightBackground = defaults.bool(forKey: "com-bps-previewjson-do-use-light") 372 | self.doShowRawJson = defaults.bool(forKey: "com-bps-previewjson-show-bad-json") 373 | /* REMOVED 1.1.0 374 | self.codeColourHex = defaults.string(forKey: "com-bps-previewjson-code-colour-hex") ?? BUFFOON_CONSTANTS.KEY_COLOUR_HEX 375 | self.markColourHex = defaults.string(forKey: "com-bps-previewjson-mark-colour-hex") ?? BUFFOON_CONSTANTS.MARK_COLOUR_HEX 376 | */ 377 | self.doShowFurniture = defaults.bool(forKey: "com-bps-previewjson-do-indent-scalars") 378 | self.boolStyle = defaults.integer(forKey: "com-bps-previewjson-bool-style") 379 | 380 | // FROM 1.1.0 381 | self.displayColours["key"] = defaults.string(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.KEY_COLOUR) 382 | self.displayColours["string"] = defaults.string(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.STRING_COLOUR) ?? BUFFOON_CONSTANTS.STRING_COLOUR_HEX 383 | self.displayColours["special"] = defaults.string(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.SPECIAL_COLOUR) ?? BUFFOON_CONSTANTS.SPECIAL_COLOUR_HEX 384 | self.displayColours["mark"] = defaults.string(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.MARK_COLOUR) ?? BUFFOON_CONSTANTS.MARK_COLOUR_HEX 385 | } 386 | 387 | // Get the menu item index from the stored value 388 | // NOTE The index is that of the list of available fonts (see 'Common.swift') so 389 | // we need to convert this to an equivalent menu index because the menu also 390 | // contains a separator and two title items 391 | let index: Int = BUFFOON_CONSTANTS.FONT_SIZE_OPTIONS.lastIndex(of: self.fontSize) ?? 3 392 | self.fontSizeSlider.floatValue = Float(index) 393 | self.fontSizeLabel.stringValue = "\(Int(BUFFOON_CONSTANTS.FONT_SIZE_OPTIONS[index]))pt" 394 | 395 | // Set the checkboxes 396 | self.useLightCheckbox.state = self.doShowLightBackground ? .on : .off 397 | self.doShowRawJsonCheckbox.state = self.doShowRawJson ? .on : .off 398 | self.doShowJsonFurnitureCheckbox.state = self.doShowFurniture ? .on : .off 399 | 400 | // Set the indents popup 401 | let indents: [Int] = [1, 2, 4, 8, BUFFOON_CONSTANTS.TABULATION_INDENT_VALUE] 402 | self.codeIndentPopup.selectItem(at: indents.firstIndex(of: self.indentDepth)!) 403 | 404 | // Set the colour panel's initial view 405 | NSColorPanel.setPickerMode(.RGB) 406 | self.codeColorWell.color = NSColor.hexToColour(self.displayColours["key"] ?? BUFFOON_CONSTANTS.KEY_COLOUR_HEX) 407 | //self.markColorWell.color = NSColor.hexToColour(self.markColourHex) 408 | 409 | // Set the font name popup 410 | // List the current system's monospace fonts 411 | self.codeFontPopup.removeAllItems() 412 | for i: Int in 0.. Bool { 612 | 613 | var haveChanged: Bool = false 614 | 615 | // Check for and record a use light background change 616 | var state: Bool = self.useLightCheckbox.state == .on 617 | haveChanged = (self.doShowLightBackground != state) 618 | 619 | // Check for and record a raw JSON presentation change 620 | if !haveChanged { 621 | state = self.doShowRawJsonCheckbox.state == .on 622 | haveChanged = (self.doShowRawJson != state) 623 | } 624 | 625 | // Check for and record a JSON marker presentation change 626 | if !haveChanged { 627 | state = self.doShowJsonFurnitureCheckbox.state == .on 628 | haveChanged = (self.doShowFurniture != state) 629 | } 630 | 631 | // Check for and record an indent change 632 | if !haveChanged { 633 | let indents: [Int] = [1, 2, 4, 8, BUFFOON_CONSTANTS.TABULATION_INDENT_VALUE] 634 | haveChanged = (self.indentDepth != indents[self.codeIndentPopup.indexOfSelectedItem]) 635 | } 636 | 637 | // Check for and record a font and style change 638 | if let fontName: String = getPostScriptName() { 639 | if !haveChanged { 640 | haveChanged = (fontName != self.fontName) 641 | } 642 | } 643 | 644 | // Check for and record a font size change 645 | if !haveChanged { 646 | haveChanged = (self.fontSize != BUFFOON_CONSTANTS.FONT_SIZE_OPTIONS[Int(self.fontSizeSlider.floatValue)]) 647 | } 648 | 649 | // Check for and record a JSON bool style change 650 | if !haveChanged { 651 | haveChanged = (self.boolStyle != self.boolStyleSegment.selectedSegment) 652 | } 653 | 654 | // FROM 1.1.0 655 | if let _ = self.displayColours["new_key"] { 656 | haveChanged = true 657 | } 658 | 659 | if let _ = self.displayColours["new_string"] { 660 | haveChanged = true 661 | } 662 | 663 | if let _ = self.displayColours["new_special"] { 664 | haveChanged = true 665 | } 666 | 667 | if let _ = self.displayColours["new_mark"] { 668 | haveChanged = true 669 | } 670 | 671 | return haveChanged 672 | } 673 | 674 | 675 | /** 676 | Zap any temporary colour values. 677 | FROM 1.1.0 678 | 679 | */ 680 | private func clearNewColours() { 681 | 682 | let keys: [String] = ["key", "string", "special", "mark"] 683 | for key in keys { 684 | if let _: String = self.displayColours["new_" + key] { 685 | self.displayColours["new_" + key] = nil 686 | } 687 | } 688 | } 689 | 690 | 691 | /** 692 | When the font size slider is moved and released, this function updates the font size readout. 693 | 694 | - Parameters: 695 | - sender: The source of the action. 696 | */ 697 | @IBAction private func doMoveSlider(sender: Any) { 698 | 699 | let index: Int = Int(self.fontSizeSlider.floatValue) 700 | self.fontSizeLabel.stringValue = "\(Int(BUFFOON_CONSTANTS.FONT_SIZE_OPTIONS[index]))pt" 701 | //self.havePrefsChanged = false 702 | } 703 | 704 | 705 | /** 706 | Called when the user selects a font from either list. 707 | 708 | FROM 1.1.0 709 | 710 | - Parameters: 711 | - sender: The source of the action. 712 | */ 713 | @IBAction private func doUpdateFonts(sender: Any) { 714 | 715 | //self.havePrefsChanged = false 716 | setStylePopup() 717 | } 718 | 719 | 720 | /** 721 | Generic IBAction for any Prefs control to register it has been used. 722 | 723 | - Parameters: 724 | - sender: The source of the action. 725 | */ 726 | @IBAction private func checkboxClicked(sender: Any) { 727 | 728 | //self.havePrefsChanged = true 729 | } 730 | 731 | 732 | /** 733 | Update the colour preferences dictionary with a value from the 734 | colour well when a colour is chosen. 735 | FROM 1.1.0 736 | 737 | - Parameters: 738 | - sender: The source of the action. 739 | */ 740 | @objc @IBAction private func colourSelected(sender: Any) { 741 | 742 | let keys: [String] = ["key", "string", "special", "mark"] 743 | let key: String = "new_" + keys[self.colourSelectionPopup.indexOfSelectedItem] 744 | self.displayColours[key] = self.codeColorWell.color.hexString 745 | //self.havePrefsChanged = true 746 | } 747 | 748 | 749 | /** 750 | Update the colour well with the stored colour: either a new one, previously 751 | chosen, or the loaded preference. 752 | FROM 1.1.0 753 | 754 | - Parameters: 755 | - sender: The source of the action. 756 | */ 757 | @IBAction private func doChooseColourType(sender: Any) { 758 | 759 | let keys: [String] = ["key", "string", "special", "mark"] 760 | let key: String = keys[self.colourSelectionPopup.indexOfSelectedItem] 761 | 762 | // If there's no `new_xxx` key, the next line will evaluate to false 763 | if let colour: String = self.displayColours["new_" + key] { 764 | if colour.count != 0 { 765 | // Set the colourwell with the updated colour and exit 766 | self.codeColorWell.color = NSColor.hexToColour(colour) 767 | return 768 | } 769 | } 770 | 771 | // Set the colourwell with the stored colour 772 | if let colour: String = self.displayColours[key] { 773 | self.codeColorWell.color = NSColor.hexToColour(colour) 774 | } 775 | } 776 | 777 | 778 | // MARK: - What's New Sheet Functions 779 | 780 | /** 781 | Show the **What's New** sheet. 782 | 783 | If we're on a new, non-patch version, of the user has explicitly 784 | asked to see it with a menu click See if we're coming from a menu click 785 | (`sender != self`) or directly in code from *appDidFinishLoading()* 786 | (`sender == self`) 787 | 788 | - Parameters: 789 | - sender: The source of the action. 790 | */ 791 | @IBAction private func doShowWhatsNew(_ sender: Any) { 792 | 793 | // See if we're coming from a menu click (sender != self) or 794 | // directly in code from 'appDidFinishLoading()' (sender == self) 795 | var doShowSheet: Bool = type(of: self) != type(of: sender) 796 | 797 | if !doShowSheet { 798 | // We are coming from the 'appDidFinishLoading()' so check 799 | // if we need to show the sheet by the checking the prefs 800 | if let defaults = UserDefaults(suiteName: self.appSuiteName) { 801 | // Get the version-specific preference key 802 | let key: String = BUFFOON_CONSTANTS.PREFS_KEYS.WHATS_NEW + getVersion() 803 | doShowSheet = defaults.bool(forKey: key) 804 | } 805 | } 806 | 807 | // Configure and show the sheet 808 | if doShowSheet { 809 | // FROM 1.0.3 810 | // Hide menus we don't want used while panel is open 811 | hidePanelGenerators() 812 | 813 | // First, get the folder path 814 | let htmlFolderPath = Bundle.main.resourcePath! + "/new" 815 | 816 | // Set up the WKWebBiew: no elasticity, horizontal scroller 817 | self.whatsNewWebView.enclosingScrollView?.hasHorizontalScroller = false 818 | self.whatsNewWebView.enclosingScrollView?.horizontalScrollElasticity = .none 819 | self.whatsNewWebView.enclosingScrollView?.verticalScrollElasticity = .none 820 | 821 | // Just in case, make sure we can load the file 822 | if FileManager.default.fileExists(atPath: htmlFolderPath) { 823 | let htmlFileURL = URL.init(fileURLWithPath: htmlFolderPath + "/new.html") 824 | let htmlFolderURL = URL.init(fileURLWithPath: htmlFolderPath) 825 | self.whatsNewNav = self.whatsNewWebView.loadFileURL(htmlFileURL, allowingReadAccessTo: htmlFolderURL) 826 | } 827 | } 828 | } 829 | 830 | 831 | /** 832 | Close the **What's New** sheet. 833 | 834 | Make sure we clear the preference flag for this minor version, so that 835 | the sheet is not displayed next time the app is run (unless the version changes) 836 | 837 | - Parameters: 838 | - sender: The source of the action. 839 | */ 840 | @IBAction private func doCloseWhatsNew(_ sender: Any) { 841 | 842 | // Close the sheet 843 | self.window.endSheet(self.whatsNewWindow) 844 | 845 | // Scroll the web view back to the top 846 | self.whatsNewWebView.evaluateJavaScript("window.scrollTo(0,0)", completionHandler: nil) 847 | 848 | // Set this version's preference 849 | if let defaults = UserDefaults(suiteName: self.appSuiteName) { 850 | let key: String = BUFFOON_CONSTANTS.PREFS_KEYS.WHATS_NEW + getVersion() 851 | defaults.setValue(false, forKey: key) 852 | 853 | #if DEBUG 854 | print("\(key) reset back to true") 855 | defaults.setValue(true, forKey: key) 856 | #endif 857 | 858 | defaults.synchronize() 859 | } 860 | 861 | // FROM 1.0.3 862 | // Restore menus 863 | showPanelGenerators() 864 | } 865 | 866 | 867 | // MARK: - Misc Functions 868 | 869 | /** 870 | Called by the app at launch to register its initial defaults. 871 | */ 872 | private func registerPreferences() { 873 | 874 | // Check if each preference value exists -- set if it doesn't 875 | if let defaults = UserDefaults(suiteName: self.appSuiteName) { 876 | // Preview body font size, stored as a CGFloat 877 | // Default: 16.0 878 | let bodyFontSizeDefault: Any? = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BODY_SIZE) 879 | if bodyFontSizeDefault == nil { 880 | defaults.setValue(CGFloat(BUFFOON_CONSTANTS.BASE_PREVIEW_FONT_SIZE), 881 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BODY_SIZE) 882 | } 883 | 884 | // Thumbnail view base font size, stored as a CGFloat, not currently used 885 | // Default: 28.0 886 | let thumbFontSizeDefault: Any? = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.THUMB_SIZE) 887 | if thumbFontSizeDefault == nil { 888 | defaults.setValue(CGFloat(BUFFOON_CONSTANTS.BASE_THUMB_FONT_SIZE), 889 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.THUMB_SIZE) 890 | } 891 | 892 | // Colour of JSON keys in the preview, stored as a hex string 893 | // Default: #CA0D0E 894 | var colourDefault: Any? = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.KEY_COLOUR) 895 | if colourDefault == nil { 896 | defaults.setValue(BUFFOON_CONSTANTS.KEY_COLOUR_HEX, 897 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.KEY_COLOUR) 898 | } 899 | 900 | // Colour of JSON markers in the preview, stored as a hex string 901 | // Default: #0096FF 902 | colourDefault = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.MARK_COLOUR) 903 | if colourDefault == nil { 904 | defaults.setValue(BUFFOON_CONSTANTS.MARK_COLOUR_HEX, 905 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.MARK_COLOUR) 906 | } 907 | 908 | // Font for previews and thumbnails 909 | // Default: Courier 910 | let fontName: Any? = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BODY_FONT) 911 | if fontName == nil { 912 | defaults.setValue(BUFFOON_CONSTANTS.BODY_FONT_NAME, 913 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BODY_FONT) 914 | } 915 | 916 | // Use light background even in dark mode, stored as a bool 917 | // Default: false 918 | let useLightDefault: Any? = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.USE_LIGHT) 919 | if useLightDefault == nil { 920 | defaults.setValue(false, 921 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.USE_LIGHT) 922 | } 923 | 924 | // Show the What's New sheet 925 | // Default: true 926 | // This is a version-specific preference suffixed with, eg, '-2-3'. Once created 927 | // this will persist, but with each new major and/or minor version, we make a 928 | // new preference that will be read by 'doShowWhatsNew()' to see if the sheet 929 | // should be shown this run 930 | let key: String = BUFFOON_CONSTANTS.PREFS_KEYS.WHATS_NEW + getVersion() 931 | let showNewDefault: Any? = defaults.object(forKey: key) 932 | if showNewDefault == nil { 933 | defaults.setValue(true, forKey: key) 934 | } 935 | 936 | // Record the preferred indent depth in spaces 937 | // Default: 8 938 | let indentDefault: Any? = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.INDENT) 939 | if indentDefault == nil { 940 | defaults.setValue(BUFFOON_CONSTANTS.JSON_INDENT, 941 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.INDENT) 942 | } 943 | 944 | // Despite var names, should we show JSON furniture? 945 | // Default: true 946 | let indentScalarsDefault: Any? = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.SCALARS) 947 | if indentScalarsDefault == nil { 948 | defaults.setValue(true, 949 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.SCALARS) 950 | } 951 | 952 | // Present malformed JSON on error? 953 | // Default: false 954 | let presentBadJsonDefault: Any? = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BAD) 955 | if presentBadJsonDefault == nil { 956 | defaults.setValue(false, 957 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BAD) 958 | } 959 | 960 | // Set the boolean presentation style 961 | // Default: false 962 | let boolStyle: Any? = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BOOL_STYLE) 963 | if boolStyle == nil { 964 | defaults.setValue(BUFFOON_CONSTANTS.BOOL_STYLE.FULL, 965 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.BOOL_STYLE) 966 | } 967 | 968 | // FROM 1.1.0 969 | // Colour of strings in the preview, stored as a hex string 970 | // Default: #FC6A5DFF 971 | colourDefault = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.STRING_COLOUR) 972 | if colourDefault == nil { 973 | defaults.setValue(BUFFOON_CONSTANTS.STRING_COLOUR_HEX, 974 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.STRING_COLOUR) 975 | } 976 | 977 | // Colour of special values (Bools, NULL, etc) in the preview, stored as a hex string 978 | // Default: #FC6A5DFF 979 | colourDefault = defaults.object(forKey: BUFFOON_CONSTANTS.PREFS_KEYS.SPECIAL_COLOUR) 980 | if colourDefault == nil { 981 | defaults.setValue(BUFFOON_CONSTANTS.SPECIAL_COLOUR_HEX, 982 | forKey: BUFFOON_CONSTANTS.PREFS_KEYS.SPECIAL_COLOUR) 983 | } 984 | 985 | // Sync any additions 986 | defaults.synchronize() 987 | } 988 | 989 | } 990 | 991 | 992 | /** 993 | Handler for macOS UI mode change notifications 994 | */ 995 | @objc private func interfaceModeChanged() { 996 | 997 | if self.preferencesWindow.isVisible { 998 | // Prefs window is up, so switch the use light background checkbox 999 | // on or off according to whether the current mode is light 1000 | // NOTE For light mode, this checkbox is irrelevant, so the 1001 | // checkbox should be disabled 1002 | let appearance: NSAppearance = NSApp.effectiveAppearance 1003 | if let appearName: NSAppearance.Name = appearance.bestMatch(from: [.aqua, .darkAqua]) { 1004 | // NOTE Appearance it this point seems to reflect the mode 1005 | // we're coming FROM, not what it has changed to 1006 | self.useLightCheckbox.isHidden = (appearName != .aqua) 1007 | } 1008 | } 1009 | } 1010 | 1011 | } 1012 | 1013 | 1014 | --------------------------------------------------------------------------------