Improve JSON editor for iOS

This commit is contained in:
世界
2025-11-26 22:25:52 +08:00
parent 264c37218c
commit 3f1c716a0a
18 changed files with 3129 additions and 17 deletions
@@ -29,26 +29,30 @@
}
}
} else {
viewBuilder {
if readOnly {
TextEditor(text: .constant(viewModel.profileContent))
} else {
TextEditor(text: $viewModel.profileContent)
}
}
.font(Font.system(.caption2, design: .monospaced))
.autocorrectionDisabled(true)
.textContentType(.init(rawValue: ""))
#if os(iOS)
.keyboardType(.asciiCapable)
.textInputAutocapitalization(.none)
.background(Color(UIColor.secondarySystemGroupedBackground))
#elseif os(macOS)
.padding()
#endif
RunestoneTextView(
text: readOnly ? .constant(viewModel.profileContent) : $viewModel.profileContent,
isEditable: !readOnly
)
.onChangeCompat(of: viewModel.profileContent) {
viewModel.markAsChanged()
}
#elseif os(macOS)
viewBuilder {
if readOnly {
TextEditor(text: .constant(viewModel.profileContent))
} else {
TextEditor(text: $viewModel.profileContent)
}
}
.font(Font.system(.caption2, design: .monospaced))
.autocorrectionDisabled(true)
.textContentType(.init(rawValue: ""))
.padding()
.onChangeCompat(of: viewModel.profileContent) {
viewModel.markAsChanged()
}
#endif
}
}
.alertBinding($viewModel.alert)
@@ -0,0 +1,73 @@
#if os(iOS)
import Runestone
import UIKit
final class ProfileEditorTheme: Theme {
let font: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular)
let textColor: UIColor = .label
let gutterBackgroundColor: UIColor = .secondarySystemBackground
let gutterHairlineColor: UIColor = .separator
let lineNumberColor: UIColor = .secondaryLabel
let lineNumberFont: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular)
let selectedLineBackgroundColor: UIColor = .systemFill
let selectedLinesLineNumberColor: UIColor = .label
let selectedLinesGutterBackgroundColor: UIColor = .secondarySystemBackground
let invisibleCharactersColor: UIColor = .tertiaryLabel
let pageGuideHairlineColor: UIColor = .separator
let pageGuideBackgroundColor: UIColor = .secondarySystemBackground
let markedTextBackgroundColor: UIColor = .systemFill
let markedTextBackgroundCornerRadius: CGFloat = 4
func textColor(for rawHighlightName: String) -> UIColor? {
guard let highlightName = HighlightName(rawHighlightName) else {
return nil
}
switch highlightName {
case .comment:
return .secondaryLabel
case .property:
return .systemCyan
case .string:
return .systemGreen
case .number:
return .systemOrange
case .constantBuiltin:
return .systemPurple
case .error:
return .systemRed
}
}
func fontTraits(for _: String) -> FontTraits {
[]
}
}
private enum HighlightName: String {
case comment
case property
case string
case number
case constantBuiltin = "constant.builtin"
case error
init?(_ rawHighlightName: String) {
var components = rawHighlightName.split(separator: ".")
while !components.isEmpty {
let candidateRawHighlightName = components.joined(separator: ".")
if let highlightName = Self(rawValue: candidateRawHighlightName) {
self = highlightName
return
}
components.removeLast()
}
return nil
}
}
#endif
@@ -0,0 +1,85 @@
#if os(iOS)
import Runestone
import SwiftUI
import TreeSitterJSON5Runestone
struct RunestoneTextView: UIViewRepresentable {
@Binding var text: String
let isEditable: Bool
func makeUIView(context: Context) -> TextView {
let textView = TextView()
textView.showLineNumbers = true
textView.isLineWrappingEnabled = false
textView.showTabs = false
textView.showSpaces = false
textView.showLineBreaks = false
textView.showSoftLineBreaks = false
textView.showNonBreakingSpaces = false
textView.autocorrectionType = .no
textView.autocapitalizationType = .none
textView.smartDashesType = .no
textView.smartQuotesType = .no
textView.smartInsertDeleteType = .no
textView.backgroundColor = .secondarySystemGroupedBackground
textView.contentInsetAdjustmentBehavior = .always
textView.alwaysBounceVertical = true
textView.kern = 0.3
textView.lineHeightMultiplier = 1.3
textView.characterPairs = [
BasicCharacterPair(leading: "{", trailing: "}"),
BasicCharacterPair(leading: "[", trailing: "]"),
BasicCharacterPair(leading: "\"", trailing: "\""),
]
let theme = ProfileEditorTheme()
let state = TextViewState(text: text, theme: theme, language: .json5)
textView.setState(state)
textView.isEditable = isEditable
textView.editorDelegate = context.coordinator
return textView
}
func updateUIView(_ textView: TextView, context _: Context) {
if textView.text != text {
textView.text = text
}
if textView.isEditable != isEditable {
textView.isEditable = isEditable
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
final class Coordinator: TextViewDelegate {
var parent: RunestoneTextView
init(_ parent: RunestoneTextView) {
self.parent = parent
}
func textViewDidChange(_ textView: TextView) {
parent.text = textView.text
}
}
}
final class BasicCharacterPair: CharacterPair {
let leading: String
let trailing: String
init(leading: String, trailing: String) {
self.leading = leading
self.trailing = trailing
}
}
#endif
+2
View File
@@ -0,0 +1,2 @@
.build/
.swiftpm/
@@ -0,0 +1,25 @@
{
"object": {
"pins": [
{
"package": "Runestone",
"repositoryURL": "https://github.com/simonbs/Runestone",
"state": {
"branch": null,
"revision": "1fad339aab99cf2136ce6bf8c32da3265b2e85e5",
"version": "0.5.1"
}
},
{
"package": "TreeSitter",
"repositoryURL": "https://github.com/tree-sitter/tree-sitter",
"state": {
"branch": null,
"revision": "98be227227af10cc7a269cb3ffb23686c0610b17",
"version": "0.20.9"
}
}
]
},
"version": 1
}
+23
View File
@@ -0,0 +1,23 @@
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "TreeSitterJSON5",
platforms: [
.iOS(.v14), .macCatalyst(.v14),
],
products: [
.library(name: "TreeSitterJSON5", targets: ["TreeSitterJSON5"]),
.library(name: "TreeSitterJSON5Queries", targets: ["TreeSitterJSON5Queries"]),
.library(name: "TreeSitterJSON5Runestone", targets: ["TreeSitterJSON5Runestone"]),
],
dependencies: [
.package(url: "https://github.com/simonbs/Runestone", from: "0.3.0"),
],
targets: [
.target(name: "TreeSitterJSON5", cSettings: [.headerSearchPath("src")]),
.target(name: "TreeSitterJSON5Queries", resources: [.copy("highlights.scm"), .copy("injections.scm")]),
.target(name: "TreeSitterJSON5Runestone", dependencies: ["Runestone", "TreeSitterJSON5", "TreeSitterJSON5Queries"]),
]
)
+3
View File
@@ -0,0 +1,3 @@
# TreeSitterJSON5
Copied from https://github.com/Lakr233/FlowDown
@@ -0,0 +1,10 @@
#ifdef __cplusplus
extern "C" {
#endif
typedef struct TSLanguage TSLanguage;
const TSLanguage *tree_sitter_json5(void);
#ifdef __cplusplus
}
#endif
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,223 @@
#ifndef TREE_SITTER_PARSER_H_
#define TREE_SITTER_PARSER_H_
#ifdef __cplusplus
extern "C" {
#endif
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#define ts_builtin_sym_error ((TSSymbol)-1)
#define ts_builtin_sym_end 0
#define TREE_SITTER_SERIALIZATION_BUFFER_SIZE 1024
typedef uint16_t TSStateId;
#ifndef TREE_SITTER_API_H_
typedef uint16_t TSSymbol;
typedef uint16_t TSFieldId;
typedef struct TSLanguage TSLanguage;
#endif
typedef struct {
TSFieldId field_id;
uint8_t child_index;
bool inherited;
} TSFieldMapEntry;
typedef struct {
uint16_t index;
uint16_t length;
} TSFieldMapSlice;
typedef struct {
bool visible;
bool named;
bool supertype;
} TSSymbolMetadata;
typedef struct TSLexer TSLexer;
struct TSLexer {
int32_t lookahead;
TSSymbol result_symbol;
void (*advance)(TSLexer *, bool);
void (*mark_end)(TSLexer *);
uint32_t (*get_column)(TSLexer *);
bool (*is_at_included_range_start)(const TSLexer *);
bool (*eof)(const TSLexer *);
};
typedef enum {
TSParseActionTypeShift,
TSParseActionTypeReduce,
TSParseActionTypeAccept,
TSParseActionTypeRecover,
} TSParseActionType;
typedef union {
struct {
uint8_t type;
TSStateId state;
bool extra;
bool repetition;
} shift;
struct {
uint8_t type;
uint8_t child_count;
TSSymbol symbol;
int16_t dynamic_precedence;
uint16_t production_id;
} reduce;
uint8_t type;
} TSParseAction;
typedef struct {
uint16_t lex_state;
uint16_t external_lex_state;
} TSLexMode;
typedef union {
TSParseAction action;
struct {
uint8_t count;
bool reusable;
} entry;
} TSParseActionEntry;
struct TSLanguage {
uint32_t version;
uint32_t symbol_count;
uint32_t alias_count;
uint32_t token_count;
uint32_t external_token_count;
uint32_t state_count;
uint32_t large_state_count;
uint32_t production_id_count;
uint32_t field_count;
uint16_t max_alias_sequence_length;
const uint16_t *parse_table;
const uint16_t *small_parse_table;
const uint32_t *small_parse_table_map;
const TSParseActionEntry *parse_actions;
const char * const *symbol_names;
const char * const *field_names;
const TSFieldMapSlice *field_map_slices;
const TSFieldMapEntry *field_map_entries;
const TSSymbolMetadata *symbol_metadata;
const TSSymbol *public_symbol_map;
const uint16_t *alias_map;
const TSSymbol *alias_sequences;
const TSLexMode *lex_modes;
bool (*lex_fn)(TSLexer *, TSStateId);
bool (*keyword_lex_fn)(TSLexer *, TSStateId);
TSSymbol keyword_capture_token;
struct {
const bool *states;
const TSSymbol *symbol_map;
void *(*create)(void);
void (*destroy)(void *);
bool (*scan)(void *, TSLexer *, const bool *symbol_whitelist);
unsigned (*serialize)(void *, char *);
void (*deserialize)(void *, const char *, unsigned);
} external_scanner;
};
/*
* Lexer Macros
*/
#define START_LEXER() \
bool result = false; \
bool skip = false; \
bool eof = false; \
int32_t lookahead; \
goto start; \
next_state: \
lexer->advance(lexer, skip); \
start: \
skip = false; \
lookahead = lexer->lookahead;
#define ADVANCE(state_value) \
{ \
state = state_value; \
goto next_state; \
}
#define SKIP(state_value) \
{ \
skip = true; \
state = state_value; \
goto next_state; \
}
#define ACCEPT_TOKEN(symbol_value) \
result = true; \
lexer->result_symbol = symbol_value; \
lexer->mark_end(lexer);
#define END_STATE() return result;
/*
* Parse Table Macros
*/
#define SMALL_STATE(id) id - LARGE_STATE_COUNT
#define STATE(id) id
#define ACTIONS(id) id
#define SHIFT(state_value) \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.state = state_value \
} \
}}
#define SHIFT_REPEAT(state_value) \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.state = state_value, \
.repetition = true \
} \
}}
#define SHIFT_EXTRA() \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.extra = true \
} \
}}
#define REDUCE(symbol_val, child_count_val, ...) \
{{ \
.reduce = { \
.type = TSParseActionTypeReduce, \
.symbol = symbol_val, \
.child_count = child_count_val, \
__VA_ARGS__ \
}, \
}}
#define RECOVER() \
{{ \
.type = TSParseActionTypeRecover \
}}
#define ACCEPT_INPUT() \
{{ \
.type = TSParseActionTypeAccept \
}}
#ifdef __cplusplus
}
#endif
#endif // TREE_SITTER_PARSER_H_
@@ -0,0 +1,17 @@
import Foundation
public enum Query {
public static var highlightsFileURL: URL {
url(named: "highlights")
}
public static var injectionsFileURL: URL {
url(named: "injections")
}
}
private extension Query {
static func url(named filename: String) -> URL {
Bundle.module.url(forResource: filename, withExtension: "scm")!
}
}
@@ -0,0 +1,15 @@
(number) @number
(comment) @comment
(member name: (_) @property)
(member value: (string) @string)
(member value: (number) @number)
(member value: ["true" "false" "null"] @constant.builtin)
(array ((string) @string))
(array (number) @number)
(array ["true" "false" "null"] @constant.builtin)
(ERROR) @error
@@ -0,0 +1 @@
(comment) @comment
@@ -0,0 +1,7 @@
import Runestone
public extension TreeSitterIndentationScopes {
static var json5: TreeSitterIndentationScopes {
TreeSitterIndentationScopes(indent: ["object", "array"], outdent: ["}", "]"])
}
}
@@ -0,0 +1,11 @@
import Runestone
import TreeSitterJSON5
import TreeSitterJSON5Queries
public extension TreeSitterLanguage {
static var json5: TreeSitterLanguage {
let highlightsQuery = TreeSitterLanguage.Query(contentsOf: TreeSitterJSON5Queries.Query.highlightsFileURL)
let injectionsQuery = TreeSitterLanguage.Query(contentsOf: TreeSitterJSON5Queries.Query.injectionsFileURL)
return TreeSitterLanguage(tree_sitter_json5(), highlightsQuery: highlightsQuery, injectionsQuery: injectionsQuery, indentationScopes: .json5)
}
}
+7
View File
@@ -1232,6 +1232,7 @@
}
},
"Page" : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -1486,6 +1487,9 @@
}
}
}
},
"Share QR Code" : {
},
"Share URL as QR Code" : {
"localizations" : {
@@ -1752,6 +1756,9 @@
}
}
}
},
"Update Failed" : {
},
"Uplink" : {
"localizations" : {
+33
View File
@@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
3A017F922A4AB2E4009149FA /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = 3A017F912A4AB2E4009149FA /* GRDB */; };
3A096F8F2A4ED3DE00D4A2ED /* Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3A096F862A4ED3DE00D4A2ED /* Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
3A2E87F22ED5A91100644195 /* Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87F12ED5A91100644195 /* Runestone */; };
3A2E87FB2ED5ABDA00644195 /* TreeSitterJSON5Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 3A2E87FA2ED5ABDA00644195 /* TreeSitterJSON5Runestone */; };
3A3AA7FC2A4EFDAE002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
3A3AA7FF2A4EFDB3002F78AB /* Library.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3AEC211D2A459B4700A63465 /* Library.framework */; };
3A3DEBEB2A4FFE2D00373BF4 /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A3DEBE62A4FFA6000373BF4 /* AppIntents.framework */; };
@@ -534,6 +536,8 @@
buildActionMask = 2147483647;
files = (
3A4EAD1B2A4FEB02005435B3 /* Library.framework in Frameworks */,
3A2E87FB2ED5ABDA00644195 /* TreeSitterJSON5Runestone in Frameworks */,
3A2E87F22ED5A91100644195 /* Runestone in Frameworks */,
3A4A020D2B53E3DC004EFB87 /* QRCode in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -754,6 +758,8 @@
name = ApplicationLibrary;
packageProductDependencies = (
3A4A020C2B53E3DC004EFB87 /* QRCode */,
3A2E87F12ED5A91100644195 /* Runestone */,
3A2E87FA2ED5ABDA00644195 /* TreeSitterJSON5Runestone */,
);
productName = ApplicationLibrary;
productReference = 3A4EAD102A4FEAE6005435B3 /* ApplicationLibrary.framework */;
@@ -1058,6 +1064,8 @@
3A017F902A4AB2E4009149FA /* XCRemoteSwiftPackageReference "GRDB" */,
3A57DF3A2A4D705000690BC5 /* XCRemoteSwiftPackageReference "MacControlCenterUI" */,
3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */,
3A2E87F02ED5A91100644195 /* XCRemoteSwiftPackageReference "Runestone" */,
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */,
);
productRefGroup = 3AEC20C72A45991900A63465 /* Products */;
projectDirPath = "";
@@ -2553,6 +2561,13 @@
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Frameworks/TreeSitterJSON5;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
3A017F902A4AB2E4009149FA /* XCRemoteSwiftPackageReference "GRDB" */ = {
isa = XCRemoteSwiftPackageReference;
@@ -2562,6 +2577,14 @@
minimumVersion = 6.15.1;
};
};
3A2E87F02ED5A91100644195 /* XCRemoteSwiftPackageReference "Runestone" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/simonbs/Runestone";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.5.1;
};
};
3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/dagronf/qrcode.git";
@@ -2594,6 +2617,16 @@
package = 3A017F902A4AB2E4009149FA /* XCRemoteSwiftPackageReference "GRDB" */;
productName = GRDB;
};
3A2E87F12ED5A91100644195 /* Runestone */ = {
isa = XCSwiftPackageProductDependency;
package = 3A2E87F02ED5A91100644195 /* XCRemoteSwiftPackageReference "Runestone" */;
productName = Runestone;
};
3A2E87FA2ED5ABDA00644195 /* TreeSitterJSON5Runestone */ = {
isa = XCSwiftPackageProductDependency;
package = 3A2E87F92ED5ABCF00644195 /* XCLocalSwiftPackageReference "Frameworks/TreeSitterJSON5" */;
productName = TreeSitterJSON5Runestone;
};
3A4A020C2B53E3DC004EFB87 /* QRCode */ = {
isa = XCSwiftPackageProductDependency;
package = 3A4A020B2B53E3DC004EFB87 /* XCRemoteSwiftPackageReference "qrcode" */;
@@ -1,5 +1,5 @@
{
"originHash" : "82fb479ec8c73c3348b93e33a9102fc692bb5a0a0d6f14beb4c5f31445c36e81",
"originHash" : "48cbd083120736a1a1ce4b717dcbfdb7900aa5e2aa21991865021fb98adc2093",
"pins" : [
{
"identity" : "binarycodable",
@@ -46,6 +46,15 @@
"version" : "17.1.0"
}
},
{
"identity" : "runestone",
"kind" : "remoteSourceControl",
"location" : "https://github.com/simonbs/Runestone",
"state" : {
"revision" : "1fad339aab99cf2136ce6bf8c32da3265b2e85e5",
"version" : "0.5.1"
}
},
{
"identity" : "swift-qrcode-generator",
"kind" : "remoteSourceControl",
@@ -63,6 +72,15 @@
"revision" : "96361ba7f9dce5184d95aba8ea90d9faa4acabb2",
"version" : "1.6.1"
}
},
{
"identity" : "tree-sitter",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tree-sitter/tree-sitter",
"state" : {
"revision" : "98be227227af10cc7a269cb3ffb23686c0610b17",
"version" : "0.20.9"
}
}
],
"version" : 3