diff --git a/Package.swift b/Package.swift index 9359a393b..dfef56209 100644 --- a/Package.swift +++ b/Package.swift @@ -10,7 +10,8 @@ let package = Package( .iOS(.v14) ], products: [ - .library(name: "Runestone", targets: ["Runestone"]) + .library(name: "Runestone", targets: ["Runestone"]), + .library(name: "RunestoneSwiftUI", targets: ["RunestoneSwiftUI"]) ], dependencies: [ .package(url: "https://github.com/tree-sitter/tree-sitter", .upToNextMinor(from: "0.20.9")) @@ -22,6 +23,7 @@ let package = Package( .copy("PrivacyInfo.xcprivacy"), .process("TextView/Appearance/Theme.xcassets") ]), + .target(name: "RunestoneSwiftUI", dependencies: ["Runestone"]), .target(name: "TestTreeSitterLanguages", cSettings: [ .unsafeFlags(["-w"]) ]), diff --git a/Sources/Runestone/Library/UITextInput+Helpers.swift b/Sources/Runestone/Library/UITextInput+Helpers.swift index db115d56c..60b911840 100644 --- a/Sources/Runestone/Library/UITextInput+Helpers.swift +++ b/Sources/Runestone/Library/UITextInput+Helpers.swift @@ -6,7 +6,7 @@ import UIKit extension UITextInput where Self: NSObject { var sbs_textSelectionDisplayInteraction: UITextSelectionDisplayInteraction? { let interactionAssistantKey = "int" + "ssAnoitcare".reversed() + "istant" - let selectionViewManagerKey = "les_".reversed() + "ection" + "reganaMweiV".reversed() + let selectionViewManagerKey: String = "les_".reversed() + "ection" + "reganaMweiV".reversed() guard responds(to: Selector(interactionAssistantKey)) else { return nil } diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index e81301fa5..286efbd79 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -72,7 +72,7 @@ public extension Theme { var pageGuideHairlineWidth: CGFloat { hairlineLength } - + var markedTextBackgroundCornerRadius: CGFloat { 0 } diff --git a/Sources/Runestone/TextView/Navigation/TextLocation.swift b/Sources/Runestone/TextView/Navigation/TextLocation.swift index df587505c..f70a762a0 100644 --- a/Sources/Runestone/TextView/Navigation/TextLocation.swift +++ b/Sources/Runestone/TextView/Navigation/TextLocation.swift @@ -7,6 +7,12 @@ public struct TextLocation: Hashable, Equatable { /// Column in the line. public let column: Int + /// Initializes TextLocation from the given line and column + public init (lineNumber: Int, column: Int) { + self.lineNumber = lineNumber + self.column = column + } + init(_ linePosition: LinePosition) { self.lineNumber = linePosition.row self.column = linePosition.column diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift index aa2e44677..d089d7682 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift @@ -16,6 +16,7 @@ final class TreeSitterInternalLanguageMode: InternalLanguageMode { private let lineManager: LineManager private let rootLanguageLayer: TreeSitterLanguageLayer private let operationQueue = OperationQueue() + private let parseLock = NSLock() init(language: TreeSitterInternalLanguage, languageProvider: TreeSitterLanguageProvider?, stringView: StringView, lineManager: LineManager) { self.stringView = stringView @@ -37,7 +38,9 @@ final class TreeSitterInternalLanguageMode: InternalLanguageMode { } func parse(_ text: NSString) { - rootLanguageLayer.parse(text) + parseLock.withLock { + rootLanguageLayer.parse(text) + } } func parse(_ text: NSString, completion: @escaping ((Bool) -> Void)) { diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift index ff40f71a4..9cf1361ee 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift @@ -77,8 +77,8 @@ extension TreeSitterLanguageLayer { if let oldTree = oldTree, let newTree = tree { let changedRanges = oldTree.rangesChanged(comparingTo: newTree) for changedRange in changedRanges { - let startRow = Int(changedRange.startPoint.row) - let endRow = Int(changedRange.endPoint.row) + let startRow = Int(min(changedRange.startPoint.row, changedRange.endPoint.row)) + let endRow = Int(max(changedRange.startPoint.row, changedRange.endPoint.row)) for row in startRow ... endRow { let line = lineManager.line(atRow: row) lineChangeSet.markLineEdited(line) diff --git a/Sources/RunestoneSwiftUI/OverridingTheme.swift b/Sources/RunestoneSwiftUI/OverridingTheme.swift new file mode 100644 index 000000000..a2d58cdef --- /dev/null +++ b/Sources/RunestoneSwiftUI/OverridingTheme.swift @@ -0,0 +1,38 @@ +// +// OverridingTheme.swift +// +// +// Created by Adrian Schönig on 23/5/2022. +// + +import UIKit +import Runestone + +class OverridingTheme: Theme { + let base: Theme + + init(base: Theme) { + self.base = base + self.font = base.font + self.textColor = base.textColor + } + + var font: UIFont + var textColor: UIColor + var gutterBackgroundColor: UIColor { base.gutterBackgroundColor } + var gutterHairlineColor: UIColor { base.gutterHairlineColor } + var lineNumberColor: UIColor { base.lineNumberColor } + var lineNumberFont: UIFont { base.lineNumberFont } + var selectedLineBackgroundColor: UIColor { base.selectedLineBackgroundColor } + var selectedLinesLineNumberColor: UIColor { base.selectedLinesLineNumberColor } + var selectedLinesGutterBackgroundColor: UIColor { base.selectedLinesGutterBackgroundColor } + var invisibleCharactersColor: UIColor { base.invisibleCharactersColor } + var pageGuideHairlineColor: UIColor { base.pageGuideHairlineColor } + var pageGuideBackgroundColor: UIColor { base.pageGuideBackgroundColor } + var markedTextBackgroundColor: UIColor { base.markedTextBackgroundColor } + + func textColor(for highlightName: String) -> UIColor? { + base.textColor(for: highlightName) + } + +} diff --git a/Sources/RunestoneSwiftUI/TextEditor.swift b/Sources/RunestoneSwiftUI/TextEditor.swift new file mode 100644 index 000000000..47162de7b --- /dev/null +++ b/Sources/RunestoneSwiftUI/TextEditor.swift @@ -0,0 +1,131 @@ +// +// TextEditor.swift +// +// +// Created by Adrian Schönig on 23/5/2022. +// + +import SwiftUI +import UIKit + +@_exported import Runestone + +public struct TextEditor: UIViewRepresentable { + + @Environment(\.themeFontSize) var themeFontSize + + @Binding var text: String + + let actualTheme: OverridingTheme + public var theme: Theme { actualTheme.base } + public let language: TreeSitterLanguage? + public let configuration: Configuration + + public init(text: Binding, theme: Theme, language: TreeSitterLanguage? = nil, configuration: Configuration = .init()) { + self._text = text + self.actualTheme = OverridingTheme(base: theme) + self.language = language + self.configuration = configuration + } + + public func makeCoordinator() -> TextEditorCoordinator { + .init() + } + + public func makeUIView(context: Context) -> UIView { + let textView = TextView() + textView.apply(configuration) + + textView.editorDelegate = context.coordinator + context.coordinator.configure(text: $text, theme: actualTheme, language: language) { state in + textView.setState(state) + } + + // We assume your theme matches the device's mode + textView.backgroundColor = .systemBackground + + textView.insertionPointColor = theme.textColor + textView.selectionBarColor = theme.textColor + textView.selectionHighlightColor = theme.textColor.withAlphaComponent(0.2) + + return textView + } + + public func updateUIView(_ uiView: UIView, context: Context) { + guard let textView = uiView as? TextView else { return assertionFailure() } + + // Update from context, such as... + switch context.environment.disableAutocorrection { + case .none: textView.autocorrectionType = .default + case .some(false): textView.autocorrectionType = .yes + case .some(true): textView.autocorrectionType = .no + } + + if let fontSize = themeFontSize, fontSize != actualTheme.font.pointSize { + actualTheme.font = UIFont(descriptor: theme.font.fontDescriptor, size: fontSize) + textView.theme = actualTheme + } + + context.coordinator.configure(text: $text, theme: actualTheme, language: language) { state in + textView.setState(state) + } + } +} + +extension TextEditor { + public init(text: String, theme: Theme, language: TreeSitterLanguage? = nil, configuration: Configuration = .init()) { + var config = configuration + config.isEditable = false + self.init(text: .constant(text), theme: theme, language: language, configuration: config) + } +} + +public class TextEditorCoordinator: ObservableObject { + var text: Binding? + + func configure(text: Binding, theme: Theme, language: TreeSitterLanguage?, completion: @escaping (TextViewState) -> Void) { + guard self.text?.wrappedValue != text.wrappedValue else { return } + + self.text = text + + DispatchQueue.global(qos: .userInteractive).async { + let state: TextViewState + if let language = language { + state = TextViewState(text: text.wrappedValue, theme: theme, language: language) + } else { + state = TextViewState(text: text.wrappedValue, theme: theme) + } + DispatchQueue.main.async { + completion(state) + } + } + } +} + +extension TextEditorCoordinator: Runestone.TextViewDelegate { + public func textViewDidChange(_ textView: TextView) { + text?.wrappedValue = textView.text + } +} + +// MARK: .themeFontSize + +public struct ThemeFontSizeKey: EnvironmentKey { + public static let defaultValue: Double? = nil +} + +extension EnvironmentValues { + public var themeFontSize: Double? { + get { self[ThemeFontSizeKey.self] } + set { self[ThemeFontSizeKey.self] = newValue } + } +} + +extension View { + + /// Overrides the font size of the `RunestoneUI.TextEditor`'s theme + /// - Parameter size: Text size in points + public func themeFontSize(_ size: Double) -> some View { + environment(\.themeFontSize, size) + } +} diff --git a/Sources/RunestoneSwiftUI/TextView+Configuration.swift b/Sources/RunestoneSwiftUI/TextView+Configuration.swift new file mode 100644 index 000000000..ef6788a31 --- /dev/null +++ b/Sources/RunestoneSwiftUI/TextView+Configuration.swift @@ -0,0 +1,35 @@ +// +// TextView+Configuration.swift +// +// +// Created by Adrian Schönig on 23/5/2022. +// + +import UIKit + +@_exported import Runestone + +extension TextEditor { + + /// Configuration options of the TextEditor + public struct Configuration { + public init(isEditable: Bool = true, showLineNumbers: Bool = false) { + self.isEditable = isEditable + self.showLineNumbers = showLineNumbers + } + + /// A Boolean value that indicates whether the text view is editable. + public var isEditable: Bool = true + + /// Enable to show line numbers in the gutter. + public var showLineNumbers: Bool = false + } +} + + +extension TextView { + func apply(_ configuration: TextEditor.Configuration) { + showLineNumbers = configuration.showLineNumbers + isEditable = configuration.isEditable + } +}