├── 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 | 
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 |
40 |
41 |
42 |
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 |
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 |
185 |
186 |
187 |
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 |
--------------------------------------------------------------------------------