From 050899e7812f8d34451176b8b2b82a7909570bd6 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 23 May 2022 13:58:10 +1000 Subject: [PATCH 01/35] WIP on RunestoneSwiftUI.TextEditor --- Package.swift | 4 +- Sources/RunestoneSwiftUI/TextEditor.swift | 80 +++++++++++++++++++ .../TextView+Configuration.swift | 30 +++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 Sources/RunestoneSwiftUI/TextEditor.swift create mode 100644 Sources/RunestoneSwiftUI/TextView+Configuration.swift diff --git a/Package.swift b/Package.swift index cbcf4963c..ff769b36d 100644 --- a/Package.swift +++ b/Package.swift @@ -10,10 +10,12 @@ let package = Package( .iOS(.v14) ], products: [ - .library(name: "Runestone", targets: ["Runestone"]) + .library(name: "Runestone", targets: ["Runestone"]), + .library(name: "RunestoneSwiftUI", targets: ["RunestoneSwiftUI"]) ], targets: [ .target(name: "Runestone", dependencies: ["TreeSitter"]), + .target(name: "RunestoneSwiftUI", dependencies: ["Runestone"]), .target(name: "TreeSitter", path: "tree-sitter/lib", exclude: [ diff --git a/Sources/RunestoneSwiftUI/TextEditor.swift b/Sources/RunestoneSwiftUI/TextEditor.swift new file mode 100644 index 000000000..abf016d4d --- /dev/null +++ b/Sources/RunestoneSwiftUI/TextEditor.swift @@ -0,0 +1,80 @@ +// +// SwiftUITextView.swift +// +// +// Created by Adrian Schönig on 23/5/2022. +// + +import SwiftUI +import UIKit + +@_exported import Runestone + +public struct TextEditor: UIViewRepresentable { + + @StateObject private var preparer = StatePreparer() + + public let text: Binding + public let theme: Theme + public let language: TreeSitterLanguage? + public let configuration: TextView.Configuration + + public init(text: Binding, theme: Theme, language: TreeSitterLanguage? = nil, configuration: TextView.Configuration = .init()) { + self.text = text + self.theme = theme + self.language = language + self.configuration = configuration + } + + public func makeUIView(context: Context) -> UIView { + let textView = TextView() + textView.apply(configuration) + + textView.editorDelegate = preparer + preparer.configure(text: text, theme: theme, language: language) + + return textView + } + + public func updateUIView(_ uiView: UIView, context: Context) { + if let state = preparer.state { + (uiView as! TextView).setState(state) + } + } +} + +extension TextEditor { + public init(text: String, theme: Theme, language: TreeSitterLanguage? = nil, configuration: TextView.Configuration = .init()) { + var config = configuration + config.isEditable = false + self.init(text: .constant(text), theme: theme, language: language, configuration: config) + } +} + +fileprivate class StatePreparer: ObservableObject { + @Published var state: TextViewState? + + var text: Binding? + + func configure(text: Binding, theme: Theme, language: TreeSitterLanguage?) { + self.text = text + + DispatchQueue.global(qos: .background).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 { + self.state = state + } + } + } +} + +extension StatePreparer: Runestone.TextViewDelegate { + func textViewDidChange(_ textView: TextView) { + text?.wrappedValue = textView.text + } +} diff --git a/Sources/RunestoneSwiftUI/TextView+Configuration.swift b/Sources/RunestoneSwiftUI/TextView+Configuration.swift new file mode 100644 index 000000000..33aff3fec --- /dev/null +++ b/Sources/RunestoneSwiftUI/TextView+Configuration.swift @@ -0,0 +1,30 @@ +// +// TextView+Configuration.swift +// +// +// Created by Adrian Schönig on 23/5/2022. +// + +import UIKit + +@_exported import Runestone + +extension TextView { + public struct Configuration { + public init(isEditable: Bool = true, showLineNumbers: Bool = false) { + self.isEditable = isEditable + self.showLineNumbers = showLineNumbers + } + + public var isEditable: Bool = true + public var showLineNumbers: Bool = false + } +} + + +extension TextView { + func apply(_ configuration: Configuration) { + showLineNumbers = configuration.showLineNumbers + isEditable = configuration.isEditable + } +} From 887de36bf16aff83745deb28baf4651c1da24195 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Tue, 24 May 2022 14:35:20 +1000 Subject: [PATCH 02/35] Refactoring, prepare to override theme SwiftUI-style --- .../RunestoneSwiftUI/OverridingTheme.swift | 38 +++++++++++++++++++ Sources/RunestoneSwiftUI/TextEditor.swift | 21 ++++++---- 2 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 Sources/RunestoneSwiftUI/OverridingTheme.swift 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 index abf016d4d..cc84e1cfb 100644 --- a/Sources/RunestoneSwiftUI/TextEditor.swift +++ b/Sources/RunestoneSwiftUI/TextEditor.swift @@ -1,5 +1,5 @@ // -// SwiftUITextView.swift +// TextEditor.swift // // // Created by Adrian Schönig on 23/5/2022. @@ -31,14 +31,21 @@ public struct TextEditor: UIViewRepresentable { textView.apply(configuration) textView.editorDelegate = preparer - preparer.configure(text: text, theme: theme, language: language) + preparer.configure(text: text, theme: theme, language: language) { state in + textView.setState(state) + } return textView } public func updateUIView(_ uiView: UIView, context: Context) { - if let state = preparer.state { - (uiView as! TextView).setState(state) + 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 } } } @@ -52,11 +59,9 @@ extension TextEditor { } fileprivate class StatePreparer: ObservableObject { - @Published var state: TextViewState? - var text: Binding? - func configure(text: Binding, theme: Theme, language: TreeSitterLanguage?) { + func configure(text: Binding, theme: Theme, language: TreeSitterLanguage?, completion: @escaping (TextViewState) -> Void) { self.text = text DispatchQueue.global(qos: .background).async { @@ -67,7 +72,7 @@ fileprivate class StatePreparer: ObservableObject { state = TextViewState(text: text.wrappedValue, theme: theme) } DispatchQueue.main.async { - self.state = state + completion(state) } } } From 8d3a0edb3d09d121bd8a8c08ac1f8692c45eb6ec Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Thu, 2 Jun 2022 20:21:31 +1000 Subject: [PATCH 03/35] Add `.themeFontSize` modifier Also: Set background colour on textView --- Sources/RunestoneSwiftUI/TextEditor.swift | 36 +++++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/Sources/RunestoneSwiftUI/TextEditor.swift b/Sources/RunestoneSwiftUI/TextEditor.swift index cc84e1cfb..67ea2b8a4 100644 --- a/Sources/RunestoneSwiftUI/TextEditor.swift +++ b/Sources/RunestoneSwiftUI/TextEditor.swift @@ -14,14 +14,17 @@ public struct TextEditor: UIViewRepresentable { @StateObject private var preparer = StatePreparer() + @Environment(\.themeFontSize) var themeFontSize + public let text: Binding - public let theme: Theme + let actualTheme: OverridingTheme + public var theme: Theme { actualTheme.base } public let language: TreeSitterLanguage? public let configuration: TextView.Configuration public init(text: Binding, theme: Theme, language: TreeSitterLanguage? = nil, configuration: TextView.Configuration = .init()) { self.text = text - self.theme = theme + self.actualTheme = OverridingTheme(base: theme) self.language = language self.configuration = configuration } @@ -31,10 +34,13 @@ public struct TextEditor: UIViewRepresentable { textView.apply(configuration) textView.editorDelegate = preparer - preparer.configure(text: text, theme: theme, language: language) { state in + preparer.configure(text: text, theme: actualTheme, language: language) { state in textView.setState(state) } + // We assume your theme matches the device's mode + textView.backgroundColor = .systemBackground + return textView } @@ -47,6 +53,11 @@ public struct TextEditor: UIViewRepresentable { 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 + } } } @@ -83,3 +94,22 @@ extension StatePreparer: Runestone.TextViewDelegate { 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 { + public func themeFontSize(_ size: Double) -> some View { + environment(\.themeFontSize, size) + } +} From 2ef6d5d598b46e23e0b1fcb19e8b3f4b8ad1f42c Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Sat, 4 Jun 2022 17:21:41 +1000 Subject: [PATCH 04/35] Move Configuration to TextEditor so that it gets documentation --- Sources/RunestoneSwiftUI/TextEditor.swift | 9 ++++++--- Sources/RunestoneSwiftUI/TextView+Configuration.swift | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Sources/RunestoneSwiftUI/TextEditor.swift b/Sources/RunestoneSwiftUI/TextEditor.swift index 67ea2b8a4..2efa538d4 100644 --- a/Sources/RunestoneSwiftUI/TextEditor.swift +++ b/Sources/RunestoneSwiftUI/TextEditor.swift @@ -20,9 +20,9 @@ public struct TextEditor: UIViewRepresentable { let actualTheme: OverridingTheme public var theme: Theme { actualTheme.base } public let language: TreeSitterLanguage? - public let configuration: TextView.Configuration + public let configuration: Configuration - public init(text: Binding, theme: Theme, language: TreeSitterLanguage? = nil, configuration: TextView.Configuration = .init()) { + public init(text: Binding, theme: Theme, language: TreeSitterLanguage? = nil, configuration: Configuration = .init()) { self.text = text self.actualTheme = OverridingTheme(base: theme) self.language = language @@ -62,7 +62,7 @@ public struct TextEditor: UIViewRepresentable { } extension TextEditor { - public init(text: String, theme: Theme, language: TreeSitterLanguage? = nil, configuration: TextView.Configuration = .init()) { + 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) @@ -109,6 +109,9 @@ extension EnvironmentValues { } 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 index 33aff3fec..747f1fad7 100644 --- a/Sources/RunestoneSwiftUI/TextView+Configuration.swift +++ b/Sources/RunestoneSwiftUI/TextView+Configuration.swift @@ -9,7 +9,9 @@ import UIKit @_exported import Runestone -extension TextView { +extension TextEditor { + + /// Configuration options of the TextEditor public struct Configuration { public init(isEditable: Bool = true, showLineNumbers: Bool = false) { self.isEditable = isEditable @@ -23,7 +25,7 @@ extension TextView { extension TextView { - func apply(_ configuration: Configuration) { + func apply(_ configuration: TextEditor.Configuration) { showLineNumbers = configuration.showLineNumbers isEditable = configuration.isEditable } From d4c70c00c34183f31341fe872512cace7a457eb8 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Mon, 6 Jun 2022 08:14:19 +1000 Subject: [PATCH 05/35] Apply missing colours --- Sources/RunestoneSwiftUI/TextEditor.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/RunestoneSwiftUI/TextEditor.swift b/Sources/RunestoneSwiftUI/TextEditor.swift index 2efa538d4..1aee79314 100644 --- a/Sources/RunestoneSwiftUI/TextEditor.swift +++ b/Sources/RunestoneSwiftUI/TextEditor.swift @@ -41,6 +41,10 @@ public struct TextEditor: UIViewRepresentable { // 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 } From aa28f8de9dcdb5d76a62bb3a76b2d1a5ed839fb3 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Sat, 18 Jun 2022 19:45:38 +1000 Subject: [PATCH 06/35] Add coordinator --- Sources/RunestoneSwiftUI/TextEditor.swift | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/Sources/RunestoneSwiftUI/TextEditor.swift b/Sources/RunestoneSwiftUI/TextEditor.swift index 1aee79314..274de5c5d 100644 --- a/Sources/RunestoneSwiftUI/TextEditor.swift +++ b/Sources/RunestoneSwiftUI/TextEditor.swift @@ -12,8 +12,6 @@ import UIKit public struct TextEditor: UIViewRepresentable { - @StateObject private var preparer = StatePreparer() - @Environment(\.themeFontSize) var themeFontSize public let text: Binding @@ -28,13 +26,17 @@ public struct TextEditor: UIViewRepresentable { 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 = preparer - preparer.configure(text: text, theme: actualTheme, language: language) { state in + textView.editorDelegate = context.coordinator + context.coordinator.configure(text: text, theme: actualTheme, language: language) { state in textView.setState(state) } @@ -73,7 +75,7 @@ extension TextEditor { } } -fileprivate class StatePreparer: ObservableObject { +public class TextEditorCoordinator: ObservableObject { var text: Binding? func configure(text: Binding, theme: Theme, language: TreeSitterLanguage?, completion: @escaping (TextViewState) -> Void) { @@ -93,8 +95,8 @@ fileprivate class StatePreparer: ObservableObject { } } -extension StatePreparer: Runestone.TextViewDelegate { - func textViewDidChange(_ textView: TextView) { +extension TextEditorCoordinator: Runestone.TextViewDelegate { + public func textViewDidChange(_ textView: TextView) { text?.wrappedValue = textView.text } } From 8a6a575d84521b42461eb0bd96907cc39c3921a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 26 Aug 2022 12:04:11 +0200 Subject: [PATCH 07/35] Adds LineControllerStorage --- .../TextView/TextInput/IndentController.swift | 6 +- .../TextView/TextInput/LayoutManager.swift | 148 ++++------------ .../TextInput/LineControllerStorage.swift | 76 +++++++++ .../TextInput/LineMovementController.swift | 48 +++--- .../TextView/TextInput/TextInputView.swift | 158 ++++++++++-------- 5 files changed, 220 insertions(+), 216 deletions(-) create mode 100644 Sources/Runestone/TextView/TextInput/LineControllerStorage.swift diff --git a/Sources/Runestone/TextView/TextInput/IndentController.swift b/Sources/Runestone/TextView/TextInput/IndentController.swift index 454c9c0f2..1124500fd 100644 --- a/Sources/Runestone/TextView/TextInput/IndentController.swift +++ b/Sources/Runestone/TextView/TextInput/IndentController.swift @@ -4,6 +4,7 @@ import UIKit protocol IndentControllerDelegate: AnyObject { func indentController(_ controller: IndentController, shouldInsert text: String, in range: NSRange) func indentController(_ controller: IndentController, shouldSelect range: NSRange) + func indentControllerDidUpdateTabWidth(_ controller: IndentController) } final class IndentController { @@ -35,7 +36,10 @@ final class IndentController { let attributes: [NSAttributedString.Key: Any] = [.font: indentFont] let bounds = str.boundingRect(with: maxSize, options: options, attributes: attributes, context: nil) let tabWidth = round(bounds.size.width) - _tabWidth = tabWidth + if tabWidth != _tabWidth { + _tabWidth = tabWidth + delegate?.indentControllerDidUpdateTabWidth(self) + } return tabWidth } } diff --git a/Sources/Runestone/TextView/TextInput/LayoutManager.swift b/Sources/Runestone/TextView/TextInput/LayoutManager.swift index 02c105a6b..3222c01e7 100644 --- a/Sources/Runestone/TextView/TextInput/LayoutManager.swift +++ b/Sources/Runestone/TextView/TextInput/LayoutManager.swift @@ -6,7 +6,6 @@ protocol LayoutManagerDelegate: AnyObject { func layoutManagerDidInvalidateContentSize(_ layoutManager: LayoutManager) func layoutManager(_ layoutManager: LayoutManager, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) func layoutManagerDidChangeGutterWidth(_ layoutManager: LayoutManager) - func layoutManagerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ layoutManager: LayoutManager) } // swiftlint:disable:next type_body_length @@ -36,11 +35,8 @@ final class LayoutManager { var stringView: StringView var scrollViewWidth: CGFloat = 0 { didSet { - if scrollViewWidth != oldValue { - if isLineWrappingEnabled { - invalidateContentSize() - invalidateLines() - } + if scrollViewWidth != oldValue && isLineWrappingEnabled { + invalidateContentSize() } } } @@ -51,7 +47,7 @@ final class LayoutManager { var languageMode: InternalLanguageMode { didSet { if languageMode !== oldValue { - for (_, lineController) in lineControllers { + for lineController in lineControllerStorage { lineController.invalidateSyntaxHighlighter() lineController.invalidateSyntaxHighlighting() } @@ -68,7 +64,7 @@ final class LayoutManager { invisibleCharacterConfiguration.textColor = theme.invisibleCharactersColor gutterSelectionBackgroundView.backgroundColor = theme.selectedLinesGutterBackgroundColor lineSelectionBackgroundView.backgroundColor = theme.selectedLineBackgroundColor - for (_, lineController) in lineControllers { + for lineController in lineControllerStorage { lineController.theme = theme lineController.estimatedLineFragmentHeight = theme.font.totalLineHeight lineController.invalidateSyntaxHighlighting() @@ -114,30 +110,13 @@ final class LayoutManager { } } var invisibleCharacterConfiguration = InvisibleCharacterConfiguration() - var tabWidth: CGFloat = 10 { - didSet { - if tabWidth != oldValue { - invalidateContentSize() - invalidateLines() - } - } - } var isLineWrappingEnabled = true { didSet { if isLineWrappingEnabled != oldValue { invalidateContentSize() - invalidateLines() } } } - var lineBreakMode: LineBreakMode = .byWordWrapping { - didSet { - if lineBreakMode != oldValue { - invalidateContentSize() - invalidateLines() - } - } - } /// Leading padding inside the gutter. var gutterLeadingPadding: CGFloat = 3 { didSet { @@ -181,16 +160,16 @@ final class LayoutManager { didSet { if lineHeightMultiplier != oldValue { invalidateContentSize() - invalidateLines() } } } - var kern: CGFloat = 0 { - didSet { - if lineHeightMultiplier != oldValue { - invalidateContentSize() - invalidateLines() - } + var constrainingLineWidth: CGFloat { + if isLineWrappingEnabled { + return scrollViewWidth - leadingLineSpacing - textContainerInset.right - safeAreaInset.left - safeAreaInset.right + } else { + // Rendering multiple very long lines is very expensive. In order to let the editor remain useable, + // we set a very high maximum line width when line wrapping is disabled. + return 10_000 } } var markedRange: NSRange? { @@ -290,15 +269,6 @@ final class LayoutManager { private var shouldResetLineWidths = true private var lineWidths: [DocumentLineNodeID: CGFloat] = [:] private var lineIDTrackingWidth: DocumentLineNodeID? - private var constrainingLineWidth: CGFloat { - if isLineWrappingEnabled { - return scrollViewWidth - leadingLineSpacing - textContainerInset.right - safeAreaInset.left - safeAreaInset.right - } else { - // Rendering multiple very long lines is very expensive. In order to let the editor remain useable, - // we set a very high maximum line width when line wrapping is disabled. - return 10_000 - } - } private var insetViewport: CGRect { let x = viewport.minX - textContainerInset.left let y = viewport.minY - textContainerInset.top @@ -317,7 +287,7 @@ final class LayoutManager { } // MARK: - Rendering - private var lineControllers: [DocumentLineNodeID: LineController] = [:] + private let lineControllerStorage: LineControllerStorage private var needsLayout = false private var needsLayoutLineSelection = false private var maximumLineBreakSymbolWidth: CGFloat { @@ -332,10 +302,11 @@ final class LayoutManager { } } - init(lineManager: LineManager, languageMode: InternalLanguageMode, stringView: StringView) { + init(lineManager: LineManager, languageMode: InternalLanguageMode, stringView: StringView, lineControllerStorage: LineControllerStorage) { self.lineManager = lineManager self.languageMode = languageMode self.stringView = stringView + self.lineControllerStorage = lineControllerStorage self.linesContainerView.isUserInteractionEnabled = false self.lineNumbersContainerView.isUserInteractionEnabled = false self.gutterContainerView.isUserInteractionEnabled = false @@ -355,7 +326,7 @@ final class LayoutManager { func removeLine(withID lineID: DocumentLineNodeID) { lineWidths.removeValue(forKey: lineID) - lineControllers.removeValue(forKey: lineID) + lineControllerStorage.removeLineController(withID: lineID) if lineID == lineIDTrackingWidth { lineIDTrackingWidth = nil _textContentWidth = nil @@ -387,16 +358,6 @@ final class LayoutManager { } } - func invalidateLines() { - for (_, lineController) in lineControllers { - lineController.lineFragmentHeightMultiplier = lineHeightMultiplier - lineController.tabWidth = tabWidth - lineController.kern = kern - lineController.lineBreakMode = lineBreakMode - lineController.invalidateSyntaxHighlighting() - } - } - func redisplayVisibleLines() { // Ensure we have the correct set of visible lines. setNeedsLayout() @@ -411,7 +372,7 @@ final class LayoutManager { func redisplayLines(withIDs lineIDs: Set) { for lineID in lineIDs { - if let lineController = lineControllers[lineID] { + if let lineController = lineControllerStorage[lineID] { lineController.invalidateEverything() // Only display the line if it's currently visible on the screen. Otherwise it's enough to invalidate it and redisplay it later. if visibleLineIDs.contains(lineID) { @@ -424,7 +385,7 @@ final class LayoutManager { } func setNeedsDisplayOnLines() { - for (_, lineController) in lineControllers { + for lineController in lineControllerStorage { lineController.setNeedsDisplayOnLineFragmentViews() } } @@ -442,7 +403,7 @@ final class LayoutManager { let endLocation = min(needleRange.location + needleRange.location + peekLength, maximumLocation) let previewLength = endLocation - startLocation let previewRange = NSRange(location: startLocation, length: previewLength) - let lineControllers = lines.map(lineController(for:)) + let lineControllers = lines.map { lineControllerStorage.getOrCreateLineController(for: $0) } let localNeedleLocation = needleRange.location - startLocation let localNeedleLength = min(needleRange.length, previewRange.length) let needleInPreviewRange = NSRange(location: localNeedleLocation, length: localNeedleLength) @@ -458,7 +419,7 @@ extension LayoutManager { func caretRect(at location: Int) -> CGRect { let safeLocation = min(max(location, 0), stringView.string.length) let line = lineManager.line(containingCharacterAt: safeLocation)! - let lineController = lineController(for: line) + let lineController = lineControllerStorage.getOrCreateLineController(for: line) let localLocation = safeLocation - line.location let localCaretRect = lineController.caretRect(atIndex: localLocation) let globalYPosition = line.yPosition + localCaretRect.minY @@ -470,7 +431,7 @@ extension LayoutManager { guard let line = lineManager.line(containingCharacterAt: range.location) else { fatalError("Cannot find first rect.") } - let lineController = lineController(for: line) + let lineController = lineControllerStorage.getOrCreateLineController(for: line) let localRange = NSRange(location: range.location - line.location, length: min(range.length, line.value)) let lineContentsRect = lineController.firstRect(for: localRange) let visibleWidth = viewport.width - gutterWidth @@ -524,18 +485,18 @@ extension LayoutManager { let adjustedXPosition = point.x - leadingLineSpacing let adjustedYPosition = point.y - textContainerInset.top let adjustedPoint = CGPoint(x: adjustedXPosition, y: adjustedYPosition) - if let line = lineManager.line(containingYOffset: adjustedPoint.y), let lineController = lineControllers[line.id] { + if let line = lineManager.line(containingYOffset: adjustedPoint.y), let lineController = lineControllerStorage[line.id] { return closestIndex(to: adjustedPoint, in: lineController, showing: line) } else if adjustedPoint.y <= 0 { let firstLine = lineManager.firstLine - if let textRenderer = lineControllers[firstLine.id] { + if let textRenderer = lineControllerStorage[firstLine.id] { return closestIndex(to: adjustedPoint, in: textRenderer, showing: firstLine) } else { return 0 } } else { let lastLine = lineManager.lastLine - if adjustedPoint.y >= lastLine.yPosition, let textRenderer = lineControllers[lastLine.id] { + if adjustedPoint.y >= lastLine.yPosition, let textRenderer = lineControllerStorage[lastLine.id] { return closestIndex(to: adjustedPoint, in: textRenderer, showing: lastLine) } else { return stringView.string.length @@ -644,7 +605,7 @@ extension LayoutManager { while let line = nextLine { let lineLocation = line.location let endTypesettingLocation = min(lineLocation + line.data.length, location) - lineLocation - let lineController = lineController(for: line) + let lineController = lineControllerStorage.getOrCreateLineController(for: line) lineController.constrainingWidth = constrainingLineWidth lineController.prepareToDisplayString(toLocation: endTypesettingLocation, syntaxHighlightAsynchronously: true) let lineSize = CGSize(width: lineController.lineWidth, height: lineController.lineHeight) @@ -681,7 +642,7 @@ extension LayoutManager { appearedLineIDs.insert(line.id) // Prepare to line controller to display text. let lineLocalViewport = CGRect(x: 0, y: maxY, width: insetViewport.width, height: insetViewport.maxY - maxY) - let lineController = lineController(for: line) + let lineController = lineControllerStorage.getOrCreateLineController(for: line) let oldLineHeight = lineController.lineHeight lineController.constrainingWidth = constrainingLineWidth lineController.prepareToDisplayString(in: lineLocalViewport, syntaxHighlightAsynchronously: true) @@ -729,7 +690,7 @@ extension LayoutManager { } } for disappearedLineID in disappearedLineIDs { - let lineController = lineControllers[disappearedLineID] + let lineController = lineControllerStorage[disappearedLineID] lineController?.cancelSyntaxHighlighting() } lineNumberLabelReuseQueue.enqueueViews(withKeys: disappearedLineIDs) @@ -751,7 +712,7 @@ extension LayoutManager { if lineNumberView.superview == nil { lineNumbersContainerView.addSubview(lineNumberView) } - let lineController = lineController(for: line) + let lineController = lineControllerStorage.getOrCreateLineController(for: line) let fontLineHeight = theme.lineNumberFont.lineHeight let xPosition = safeAreaInset.left + gutterLeadingPadding var yPosition = textContainerInset.top + line.yPosition @@ -885,7 +846,7 @@ extension LayoutManager { lineWidths = [:] if let longestLine = lineManager.initialLongestLine { lineIDTrackingWidth = longestLine.id - let lineController = lineController(for: longestLine) + let lineController = lineControllerStorage.getOrCreateLineController(for: longestLine) lineController.invalidateEverything() lineWidths[longestLine.id] = lineController.lineWidth if !isLineWrappingEnabled { @@ -895,45 +856,13 @@ extension LayoutManager { } } } - - private func lineController(for line: DocumentLineNode) -> LineController { - if let cachedLineController = lineControllers[line.id] { - return cachedLineController - } else { - let lineController = LineController(line: line, stringView: stringView) - lineController.delegate = self - lineController.constrainingWidth = constrainingLineWidth - lineController.estimatedLineFragmentHeight = theme.font.totalLineHeight - lineController.lineFragmentHeightMultiplier = lineHeightMultiplier - lineController.tabWidth = tabWidth - lineController.theme = theme - lineController.lineBreakMode = lineBreakMode - lineControllers[line.id] = lineController - return lineController - } - } -} - -// MARK: - Line Movement -extension LayoutManager { - func numberOfLineFragments(in line: DocumentLineNode) -> Int { - return lineController(for: line).numberOfLineFragments - } - - func lineFragmentNode(atIndex index: Int, in line: DocumentLineNode) -> LineFragmentNode { - return lineController(for: line).lineFragmentNode(atIndex: index) - } - - func lineFragmentNode(containingCharacterAt location: Int, in line: DocumentLineNode) -> LineFragmentNode { - return lineController(for: line).lineFragmentNode(containingCharacterAt: location) - } } // MARK: - Marked Text private extension LayoutManager { private func updateMarkedTextOnVisibleLines() { for lineID in visibleLineIDs { - if let lineController = lineControllers[lineID] { + if let lineController = lineControllerStorage[lineID] { if let markedRange = markedRange { let localMarkedRange = NSRange(globalRange: markedRange, cappedLocalTo: lineController.line) lineController.setMarkedTextOnLineFragments(localMarkedRange) @@ -971,23 +900,6 @@ private extension LayoutManager { // MARK: - Memory Management private extension LayoutManager { @objc private func clearMemory() { - let allLineIDs = Set(lineControllers.keys) - let lineIDsToRelease = allLineIDs.subtracting(visibleLineIDs) - for lineID in lineIDsToRelease { - lineControllers.removeValue(forKey: lineID) - } - } -} - -// MARK: - LineControllerDelegate -extension LayoutManager: LineControllerDelegate { - func lineSyntaxHighlighter(for lineController: LineController) -> LineSyntaxHighlighter? { - let syntaxHighlighter = languageMode.createLineSyntaxHighlighter() - syntaxHighlighter.kern = kern - return syntaxHighlighter - } - - func lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ lineController: LineController) { - delegate?.layoutManagerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(self) + lineControllerStorage.removeAllLineControllers(exceptLinesWithID: visibleLineIDs) } } diff --git a/Sources/Runestone/TextView/TextInput/LineControllerStorage.swift b/Sources/Runestone/TextView/TextInput/LineControllerStorage.swift new file mode 100644 index 000000000..f377cf4bc --- /dev/null +++ b/Sources/Runestone/TextView/TextInput/LineControllerStorage.swift @@ -0,0 +1,76 @@ +protocol LineControllerStorageDelegate: AnyObject { + func lineControllerStorage(_ storage: LineControllerStorage, didCreate lineController: LineController) +} + +final class LineControllerStorage { + weak var delegate: LineControllerStorageDelegate? + subscript(_ lineID: DocumentLineNodeID) -> LineController? { + return lineControllers[lineID] + } + + fileprivate var numberOfLineControllers: Int { + return lineControllers.count + } + + var stringView: StringView { + didSet { + if stringView !== oldValue { + lineControllers.removeAll() + } + } + } + private var lineControllers: [DocumentLineNodeID: LineController] = [:] + + init(stringView: StringView) { + self.stringView = stringView + } + + func getOrCreateLineController(for line: DocumentLineNode) -> LineController { + if let cachedLineController = lineControllers[line.id] { + return cachedLineController + } else { + let lineController = LineController(line: line, stringView: stringView) + lineControllers[line.id] = lineController + delegate?.lineControllerStorage(self, didCreate: lineController) + return lineController + } + } + + func removeLineController(withID lineID: DocumentLineNodeID) { + lineControllers.removeValue(forKey: lineID) + } + + func removeAllLineControllers(exceptLinesWithID exceptionLineIDs: Set) { + let allLineIDs = Set(lineControllers.keys) + let lineIDsToRelease = allLineIDs.subtracting(exceptionLineIDs) + for lineID in lineIDsToRelease { + lineControllers.removeValue(forKey: lineID) + } + } +} + +extension LineControllerStorage: Sequence { + struct Iterator: IteratorProtocol { + private let lineControllers: [LineController] + private var index = 0 + + init(lineControllers: [LineController]) { + self.lineControllers = lineControllers + } + + mutating func next() -> LineController? { + if index < lineControllers.count { + let lineController = lineControllers[index] + index += 1 + return lineController + } else { + return nil + } + } + } + + func makeIterator() -> Iterator { + let lineControllers = Array(lineControllers.values) + return Iterator(lineControllers: lineControllers) + } +} diff --git a/Sources/Runestone/TextView/TextInput/LineMovementController.swift b/Sources/Runestone/TextView/TextInput/LineMovementController.swift index a0604d6a4..9da79d3f9 100644 --- a/Sources/Runestone/TextView/TextInput/LineMovementController.swift +++ b/Sources/Runestone/TextView/TextInput/LineMovementController.swift @@ -1,33 +1,14 @@ import UIKit -protocol LineMovementControllerDelegate: AnyObject { - func lineMovementController(_ controller: LineMovementController, numberOfLineFragmentsIn line: DocumentLineNode) -> Int - func lineMovementController( - _ controller: LineMovementController, - lineFragmentNodeContainingCharacterAt location: Int, - in line: DocumentLineNode) -> LineFragmentNode - func lineMovementController( - _ controller: LineMovementController, - lineFragmentNodeAtIndex index: Int, - in line: DocumentLineNode) -> LineFragmentNode -} - final class LineMovementController { - weak var delegate: LineMovementControllerDelegate? var lineManager: LineManager var stringView: StringView + let lineControllerStorage: LineControllerStorage - private var currentDelegate: LineMovementControllerDelegate { - if let delegate = delegate { - return delegate - } else { - fatalError("Delegate of \(type(of: self)) is unavailable") - } - } - - init(lineManager: LineManager, stringView: StringView) { + init(lineManager: LineManager, stringView: StringView, lineControllerStorage: LineControllerStorage) { self.lineManager = lineManager self.stringView = stringView + self.lineControllerStorage = lineControllerStorage } func location(from location: Int, in direction: UITextLayoutDirection, offset: Int) -> Int? { @@ -77,7 +58,7 @@ private extension LineMovementController { return location } let lineLocalLocation = max(min(location - line.location, line.data.totalLength), 0) - let lineFragmentNode = currentDelegate.lineMovementController(self, lineFragmentNodeContainingCharacterAt: lineLocalLocation, in: line) + let lineFragmentNode = lineFragmentNode(containingCharacterAt: lineLocalLocation, in: line) let lineFragmentLocalLocation = lineLocalLocation - lineFragmentNode.location return locationForMoving(lineOffset: lineOffset, fromLocation: lineFragmentLocalLocation, inLineFragmentAt: lineFragmentNode.index, of: line) } @@ -93,7 +74,7 @@ private extension LineMovementController { return locationForMovingDownwards(lineOffset: lineOffset, fromLocation: location, inLineFragmentAt: lineFragmentIndex, of: line) } else { // lineOffset is 0 so we shouldn't change the line - let destinationLineFragmentNode = currentDelegate.lineMovementController(self, lineFragmentNodeAtIndex: lineFragmentIndex, in: line) + let destinationLineFragmentNode = lineFragmentNode(atIndex: lineFragmentIndex, in: line) let lineLocation = line.location let preferredLocation = lineLocation + destinationLineFragmentNode.location + location let lineFragmentMaximumLocation = lineLocation + destinationLineFragmentNode.location + destinationLineFragmentNode.value @@ -119,7 +100,7 @@ private extension LineMovementController { return 0 } let previousLine = lineManager.line(atRow: lineIndex - 1) - let numberOfLineFragments = currentDelegate.lineMovementController(self, numberOfLineFragmentsIn: previousLine) + let numberOfLineFragments = numberOfLineFragments(in: previousLine) let newLineFragmentIndex = numberOfLineFragments - 1 return locationForMovingUpwards( lineOffset: remainingLineOffset - 1, @@ -133,7 +114,7 @@ private extension LineMovementController { fromLocation location: Int, inLineFragmentAt lineFragmentIndex: Int, of line: DocumentLineNode) -> Int { - let numberOfLineFragments = currentDelegate.lineMovementController(self, numberOfLineFragmentsIn: line) + let numberOfLineFragments = numberOfLineFragments(in: line) let takeLineCount = min(numberOfLineFragments - lineFragmentIndex - 1, lineOffset) let remainingLineOffset = lineOffset - takeLineCount guard remainingLineOffset > 0 else { @@ -147,4 +128,19 @@ private extension LineMovementController { let nextLine = lineManager.line(atRow: lineIndex + 1) return locationForMovingDownwards(lineOffset: remainingLineOffset - 1, fromLocation: location, inLineFragmentAt: 0, of: nextLine) } + + private func numberOfLineFragments(in line: DocumentLineNode) -> Int { + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + return lineController.numberOfLineFragments + } + + private func lineFragmentNode(atIndex index: Int, in line: DocumentLineNode) -> LineFragmentNode { + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + return lineController.lineFragmentNode(atIndex: index) + } + + private func lineFragmentNode(containingCharacterAt location: Int, in line: DocumentLineNode) -> LineFragmentNode { + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + return lineController.lineFragmentNode(containingCharacterAt: location) + } } diff --git a/Sources/Runestone/TextView/TextInput/TextInputView.swift b/Sources/Runestone/TextView/TextInput/TextInputView.swift index 7441a87c1..e230a3f4c 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputView.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputView.swift @@ -119,7 +119,6 @@ final class TextInputView: UIView, UITextInput { pageGuideController.guideView.hairlineColor = theme.pageGuideHairlineColor pageGuideController.guideView.backgroundColor = theme.pageGuideBackgroundColor layoutManager.theme = theme - layoutManager.tabWidth = indentController.tabWidth } } var showLineNumbers: Bool { @@ -182,7 +181,7 @@ final class TextInputView: UIView, UITextInput { set { if newValue != layoutManager.invisibleCharacterConfiguration.showLineBreaks { layoutManager.invisibleCharacterConfiguration.showLineBreaks = newValue - layoutManager.invalidateLines() + invalidateLines() layoutManager.setNeedsLayout() layoutManager.setNeedsDisplayOnLines() setNeedsLayout() @@ -196,7 +195,7 @@ final class TextInputView: UIView, UITextInput { set { if newValue != layoutManager.invisibleCharacterConfiguration.showSoftLineBreaks { layoutManager.invisibleCharacterConfiguration.showSoftLineBreaks = newValue - layoutManager.invalidateLines() + invalidateLines() layoutManager.setNeedsLayout() layoutManager.setNeedsDisplayOnLines() setNeedsLayout() @@ -262,7 +261,6 @@ final class TextInputView: UIView, UITextInput { didSet { if indentStrategy != oldValue { indentController.indentStrategy = indentStrategy - layoutManager.tabWidth = indentController.tabWidth layoutManager.setNeedsLayout() setNeedsLayout() layoutIfNeeded() @@ -312,20 +310,17 @@ final class TextInputView: UIView, UITextInput { set { if newValue != layoutManager.isLineWrappingEnabled { layoutManager.isLineWrappingEnabled = newValue - layoutManager.invalidateLines() + invalidateLines() layoutManager.setNeedsLayout() layoutManager.layoutIfNeeded() } } } - var lineBreakMode: LineBreakMode { - get { - return layoutManager.lineBreakMode - } - set { - if newValue != layoutManager.lineBreakMode { - layoutManager.lineBreakMode = newValue - layoutManager.invalidateLines() + var lineBreakMode: LineBreakMode = .byWordWrapping { + didSet { + if lineBreakMode != oldValue { + invalidateLines() + layoutManager.invalidateContentSize() layoutManager.setNeedsLayout() layoutManager.layoutIfNeeded() } @@ -334,27 +329,23 @@ final class TextInputView: UIView, UITextInput { var gutterWidth: CGFloat { return layoutManager.gutterWidth } - var lineHeightMultiplier: CGFloat { - get { - return layoutManager.lineHeightMultiplier - } - set { - if newValue != layoutManager.lineHeightMultiplier { - layoutManager.lineHeightMultiplier = newValue + var lineHeightMultiplier: CGFloat = 1 { + didSet { + if lineHeightMultiplier != oldValue { + layoutManager.lineHeightMultiplier = lineHeightMultiplier + invalidateLines() lineManager.estimatedLineHeight = estimatedLineHeight layoutManager.setNeedsLayout() setNeedsLayout() } } } - var kern: CGFloat { - get { - return layoutManager.kern - } - set { - if newValue != layoutManager.kern { - pageGuideController.kern = newValue - layoutManager.kern = newValue + var kern: CGFloat = 0 { + didSet { + if kern != oldValue { + invalidateLines() + pageGuideController.kern = kern + layoutManager.invalidateContentSize() layoutManager.setNeedsLayout() setNeedsLayout() } @@ -421,7 +412,7 @@ final class TextInputView: UIView, UITextInput { } layoutManager.invalidateContentSize() layoutManager.updateLineNumberWidth() - layoutManager.invalidateLines() + invalidateLines() layoutManager.setNeedsLayout() layoutManager.layoutIfNeeded() if !shouldPreserveUndoStackWhenSettingString { @@ -442,12 +433,14 @@ final class TextInputView: UIView, UITextInput { } } } - var scrollViewWidth: CGFloat { - get { - return layoutManager.scrollViewWidth - } - set { - layoutManager.scrollViewWidth = newValue + var scrollViewWidth: CGFloat = 0 { + didSet { + if scrollViewWidth != oldValue { + layoutManager.scrollViewWidth = scrollViewWidth + if isLineWrappingEnabled { + invalidateLines() + } + } } } var contentSize: CGSize { @@ -491,6 +484,7 @@ final class TextInputView: UIView, UITextInput { didSet { if stringView !== oldValue { lineManager.stringView = stringView + lineControllerStorage.stringView = stringView layoutManager.stringView = stringView indentController.stringView = stringView lineMovementController.stringView = stringView @@ -521,6 +515,7 @@ final class TextInputView: UIView, UITextInput { } } } + private let lineControllerStorage: LineControllerStorage private let layoutManager: LayoutManager private let timedUndoManager = TimedUndoManager() private let indentController: IndentController @@ -557,22 +552,26 @@ final class TextInputView: UIView, UITextInput { init(theme: Theme) { self.theme = theme lineManager = LineManager(stringView: stringView) - layoutManager = LayoutManager(lineManager: lineManager, languageMode: languageMode, stringView: stringView) - indentController = IndentController( - stringView: stringView, - lineManager: lineManager, - languageMode: languageMode, - indentStrategy: indentStrategy, - indentFont: theme.font) - lineMovementController = LineMovementController(lineManager: lineManager, stringView: stringView) + lineControllerStorage = LineControllerStorage(stringView: stringView) + layoutManager = LayoutManager(lineManager: lineManager, + languageMode: languageMode, + stringView: stringView, + lineControllerStorage: lineControllerStorage) + indentController = IndentController(stringView: stringView, + lineManager: lineManager, + languageMode: languageMode, + indentStrategy: indentStrategy, + indentFont: theme.font) + lineMovementController = LineMovementController(lineManager: lineManager, + stringView: stringView, + lineControllerStorage: lineControllerStorage) super.init(frame: .zero) lineManager.estimatedLineHeight = estimatedLineHeight indentController.delegate = self - lineMovementController.delegate = self + lineControllerStorage.delegate = self layoutManager.delegate = self layoutManager.textInputView = self layoutManager.theme = theme - layoutManager.tabWidth = indentController.tabWidth editMenuController.delegate = self editMenuController.setupEditMenu(in: self) } @@ -745,7 +744,7 @@ final class TextInputView: UIView, UITextInput { layoutManager.languageMode = internalLanguageMode internalLanguageMode.parse(string) { [weak self] finished in if let self = self, finished { - self.layoutManager.invalidateLines() + self.invalidateLines() self.layoutManager.setNeedsLayout() self.layoutManager.layoutIfNeeded() } @@ -810,7 +809,7 @@ final class TextInputView: UIView, UITextInput { override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { - layoutManager.invalidateLines() + invalidateLines() layoutManager.setNeedsLayout() } } @@ -886,10 +885,20 @@ private extension TextInputView { } private func performFullLayout() { - layoutManager.invalidateLines() + invalidateLines() layoutManager.setNeedsLayout() layoutManager.layoutIfNeeded() } + + private func invalidateLines() { + for lineController in lineControllerStorage { + lineController.lineFragmentHeightMultiplier = lineHeightMultiplier + lineController.tabWidth = indentController.tabWidth + lineController.kern = kern + lineController.lineBreakMode = lineBreakMode + lineController.invalidateSyntaxHighlighting() + } + } } // MARK: - Floating Caret @@ -1474,6 +1483,33 @@ extension TextInputView: TreeSitterLanguageModeDelegate { } } +// MARK: - LineControllerStorageDelegate +extension TextInputView: LineControllerStorageDelegate { + func lineControllerStorage(_ storage: LineControllerStorage, didCreate lineController: LineController) { + lineController.delegate = self + lineController.constrainingWidth = layoutManager.constrainingLineWidth + lineController.estimatedLineFragmentHeight = theme.font.totalLineHeight + lineController.lineFragmentHeightMultiplier = lineHeightMultiplier + lineController.tabWidth = indentController.tabWidth + lineController.theme = theme + lineController.lineBreakMode = lineBreakMode + } +} + +// MARK: - LineControllerDelegate +extension TextInputView: LineControllerDelegate { + func lineSyntaxHighlighter(for lineController: LineController) -> LineSyntaxHighlighter? { + let syntaxHighlighter = languageMode.createLineSyntaxHighlighter() + syntaxHighlighter.kern = kern + return syntaxHighlighter + } + + func lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ lineController: LineController) { + setNeedsLayout() + layoutManager.setNeedsLayout() + } +} + // MARK: - LayoutManagerDelegate extension TextInputView: LayoutManagerDelegate { func layoutManagerDidInvalidateContentSize(_ layoutManager: LayoutManager) { @@ -1488,15 +1524,10 @@ extension TextInputView: LayoutManagerDelegate { // Typeset lines again when the line number width changes. // Changing line number width may increase or reduce the number of line fragments in a line. setNeedsLayout() - layoutManager.invalidateLines() + invalidateLines() layoutManager.setNeedsLayout() delegate?.textInputViewDidChangeGutterWidth(self) } - - func layoutManagerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ layoutManager: LayoutManager) { - setNeedsLayout() - layoutManager.setNeedsLayout() - } } // MARK: - IndentControllerDelegate @@ -1510,24 +1541,9 @@ extension TextInputView: IndentControllerDelegate { selectedRange = range inputDelegate?.selectionDidChange(self) } -} - -// MARK: - LineMovementControllerDelegate -extension TextInputView: LineMovementControllerDelegate { - func lineMovementController(_ controller: LineMovementController, numberOfLineFragmentsIn line: DocumentLineNode) -> Int { - return layoutManager.numberOfLineFragments(in: line) - } - - func lineMovementController(_ controller: LineMovementController, - lineFragmentNodeAtIndex index: Int, - in line: DocumentLineNode) -> LineFragmentNode { - return layoutManager.lineFragmentNode(atIndex: index, in: line) - } - func lineMovementController(_ controller: LineMovementController, - lineFragmentNodeContainingCharacterAt location: Int, - in line: DocumentLineNode) -> LineFragmentNode { - return layoutManager.lineFragmentNode(containingCharacterAt: location, in: line) + func indentControllerDidUpdateTabWidth(_ controller: IndentController) { + invalidateLines() } } From 5b66923ce6336539391ac8f9950890709fb98322 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 26 Aug 2022 13:01:58 +0200 Subject: [PATCH 08/35] Fixes SwiftLint warnings --- .../Runestone/TextView/TextInput/LineControllerStorage.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/LineControllerStorage.swift b/Sources/Runestone/TextView/TextInput/LineControllerStorage.swift index f377cf4bc..8ae5ba472 100644 --- a/Sources/Runestone/TextView/TextInput/LineControllerStorage.swift +++ b/Sources/Runestone/TextView/TextInput/LineControllerStorage.swift @@ -20,11 +20,11 @@ final class LineControllerStorage { } } private var lineControllers: [DocumentLineNodeID: LineController] = [:] - + init(stringView: StringView) { self.stringView = stringView } - + func getOrCreateLineController(for line: DocumentLineNode) -> LineController { if let cachedLineController = lineControllers[line.id] { return cachedLineController From caab74fdb06309d56cb45d3f2a8526b804b8d9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 10:19:51 +0200 Subject: [PATCH 09/35] Decreases width of caret --- Sources/Runestone/Library/Caret.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/Library/Caret.swift b/Sources/Runestone/Library/Caret.swift index c68cd00ab..819d0c6f2 100644 --- a/Sources/Runestone/Library/Caret.swift +++ b/Sources/Runestone/Library/Caret.swift @@ -1,7 +1,7 @@ import UIKit enum Caret { - static let width: CGFloat = 3 + static let width: CGFloat = 2 static func defaultHeight(for font: UIFont?) -> CGFloat { return font?.lineHeight ?? 15 From 391332e447728641fbe6d4bd175ffd806d5a6e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 12:36:46 +0200 Subject: [PATCH 10/35] Places the caret correctly when navigating between lines --- .../TextView/TextInput/TextInputView.swift | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/TextInput/TextInputView.swift b/Sources/Runestone/TextView/TextInput/TextInputView.swift index 7ecef0691..f49a658d0 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputView.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputView.swift @@ -35,8 +35,20 @@ final class TextInputView: UIView, UITextInput { // It'll invoke the setter in various scenarios, for example when navigating the text using the keyboard. let newRange = (newValue as? IndexedRange)?.range if newRange != _selectedRange { - shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews = true + // The logic for determining whether or not to notify the input delegate is based on advice provided by Alexander Blach, developer of Textastic. + var shouldNotifyInputDelegate = false + if didCallPositionFromPositionInDirectionWithOffset { + shouldNotifyInputDelegate = true + didCallPositionFromPositionInDirectionWithOffset = false + } + shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews = !shouldNotifyInputDelegate + if shouldNotifyInputDelegate { + inputDelegate?.selectionWillChange(self) + } _selectedRange = newRange + if shouldNotifyInputDelegate { + inputDelegate?.selectionDidChange(self) + } delegate?.textInputViewDidChangeSelection(self) } } @@ -551,6 +563,7 @@ final class TextInputView: UIView, UITextInput { private let editMenuController = EditMenuController() // swiftlint:disable:next identifier_name private var shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews = false + private var didCallPositionFromPositionInDirectionWithOffset = false private var shouldPreserveUndoStackWhenSettingString = false // MARK: - Lifecycle @@ -1330,6 +1343,7 @@ extension TextInputView { guard let indexedPosition = position as? IndexedPosition else { return nil } + didCallPositionFromPositionInDirectionWithOffset = true guard let location = lineMovementController.location(from: indexedPosition.index, in: direction, offset: offset) else { return nil } From 1550393c071dbace4e8c500a8a82c1c5807a0826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 13:10:40 +0200 Subject: [PATCH 11/35] Removes unused function --- .../TextView/TextInput/TextInputStringTokenizer.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift index 7d140df55..f7d1da30e 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift @@ -80,8 +80,4 @@ private extension TextInputStringTokenizer { private func isBackward(_ direction: UITextDirection) -> Bool { return direction.rawValue == UITextStorageDirection.backward.rawValue || direction.rawValue == UITextLayoutDirection.left.rawValue } - - private func isForward(_ direction: UITextDirection) -> Bool { - return direction.rawValue == UITextStorageDirection.forward.rawValue || direction.rawValue == UITextLayoutDirection.right.rawValue - } } From ab8813b231af40f959ea5e60d83641a79b71bd4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 13:11:02 +0200 Subject: [PATCH 12/35] Removes mapping of direction --- .../TextInput/TextInputStringTokenizer.swift | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift index f7d1da30e..fb97914b6 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift @@ -24,14 +24,14 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { } else if granularity == .word, isCustomWordBoundry(at: indexedPosition.index) { return true } else { - return super.isPosition(position, atBoundary: granularity, inDirection: map(direction)) + return super.isPosition(position, atBoundary: granularity, inDirection: direction) } } override func isPosition(_ position: UITextPosition, withinTextUnit granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool { - return super.isPosition(position, withinTextUnit: granularity, inDirection: map(direction)) + return super.isPosition(position, withinTextUnit: granularity, inDirection: direction) } override func position(from position: UITextPosition, @@ -47,14 +47,14 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { return IndexedPosition(index: line.location + line.data.length) } } else { - return super.position(from: position, toBoundary: granularity, inDirection: map(direction)) + return super.position(from: position, toBoundary: granularity, inDirection: direction) } } override func rangeEnclosingPosition(_ position: UITextPosition, with granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextRange? { - return super.rangeEnclosingPosition(position, with: granularity, inDirection: map(direction)) + return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) } } @@ -67,16 +67,6 @@ private extension TextInputStringTokenizer { return character.unicodeScalars.allSatisfy { wordBoundryCharacterSet.contains($0) } } - private func map(_ direction: UITextDirection) -> UITextDirection { - if direction.rawValue == UITextLayoutDirection.left.rawValue { - return .storage(.backward) - } else if direction.rawValue == UITextLayoutDirection.right.rawValue { - return .storage(.forward) - } else { - return direction - } - } - private func isBackward(_ direction: UITextDirection) -> Bool { return direction.rawValue == UITextStorageDirection.backward.rawValue || direction.rawValue == UITextLayoutDirection.left.rawValue } From ecbe444178f0429b14e308e6cfeac9b645cda2df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 13:11:18 +0200 Subject: [PATCH 13/35] Improves code formatting --- .../TextInput/LineMovementController.swift | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/LineMovementController.swift b/Sources/Runestone/TextView/TextInput/LineMovementController.swift index 9da79d3f9..fcaaa1fdd 100644 --- a/Sources/Runestone/TextView/TextInput/LineMovementController.swift +++ b/Sources/Runestone/TextView/TextInput/LineMovementController.swift @@ -63,11 +63,10 @@ private extension LineMovementController { return locationForMoving(lineOffset: lineOffset, fromLocation: lineFragmentLocalLocation, inLineFragmentAt: lineFragmentNode.index, of: line) } - private func locationForMoving( - lineOffset: Int, - fromLocation location: Int, - inLineFragmentAt lineFragmentIndex: Int, - of line: DocumentLineNode) -> Int { + private func locationForMoving(lineOffset: Int, + fromLocation location: Int, + inLineFragmentAt lineFragmentIndex: Int, + of line: DocumentLineNode) -> Int { if lineOffset < 0 { return locationForMovingUpwards(lineOffset: abs(lineOffset), fromLocation: location, inLineFragmentAt: lineFragmentIndex, of: line) } else if lineOffset > 0 { @@ -84,11 +83,10 @@ private extension LineMovementController { } } - private func locationForMovingUpwards( - lineOffset: Int, - fromLocation location: Int, - inLineFragmentAt lineFragmentIndex: Int, - of line: DocumentLineNode) -> Int { + private func locationForMovingUpwards(lineOffset: Int, + fromLocation location: Int, + inLineFragmentAt lineFragmentIndex: Int, + of line: DocumentLineNode) -> Int { let takeLineCount = min(lineFragmentIndex, lineOffset) let remainingLineOffset = lineOffset - takeLineCount guard remainingLineOffset > 0 else { @@ -102,18 +100,16 @@ private extension LineMovementController { let previousLine = lineManager.line(atRow: lineIndex - 1) let numberOfLineFragments = numberOfLineFragments(in: previousLine) let newLineFragmentIndex = numberOfLineFragments - 1 - return locationForMovingUpwards( - lineOffset: remainingLineOffset - 1, - fromLocation: location, - inLineFragmentAt: newLineFragmentIndex, - of: previousLine) + return locationForMovingUpwards(lineOffset: remainingLineOffset - 1, + fromLocation: location, + inLineFragmentAt: newLineFragmentIndex, + of: previousLine) } - private func locationForMovingDownwards( - lineOffset: Int, - fromLocation location: Int, - inLineFragmentAt lineFragmentIndex: Int, - of line: DocumentLineNode) -> Int { + private func locationForMovingDownwards(lineOffset: Int, + fromLocation location: Int, + inLineFragmentAt lineFragmentIndex: Int, + of line: DocumentLineNode) -> Int { let numberOfLineFragments = numberOfLineFragments(in: line) let takeLineCount = min(numberOfLineFragments - lineFragmentIndex - 1, lineOffset) let remainingLineOffset = lineOffset - takeLineCount From 6edb05f6f5ad4ea90fdf80e45bc90ed68b76fa24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 16:48:11 +0200 Subject: [PATCH 14/35] Places caret at front of next line fragment when caret is behind last character in line fragment --- .../TextView/TextInput/LayoutManager.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/LayoutManager.swift b/Sources/Runestone/TextView/TextInput/LayoutManager.swift index 3222c01e7..5e76410b0 100644 --- a/Sources/Runestone/TextView/TextInput/LayoutManager.swift +++ b/Sources/Runestone/TextView/TextInput/LayoutManager.swift @@ -416,12 +416,20 @@ final class LayoutManager { // MARK: - UITextInput extension LayoutManager { - func caretRect(at location: Int) -> CGRect { + func caretRect(at location: Int, placeCaretAtNextLineFragmentForLastCharacter: Bool = false) -> CGRect { let safeLocation = min(max(location, 0), stringView.string.length) let line = lineManager.line(containingCharacterAt: safeLocation)! let lineController = lineControllerStorage.getOrCreateLineController(for: line) - let localLocation = safeLocation - line.location - let localCaretRect = lineController.caretRect(atIndex: localLocation) + let lineLocalLocation = safeLocation - line.location + if placeCaretAtNextLineFragmentForLastCharacter && lineLocalLocation > 0 && lineLocalLocation < line.data.totalLength { + // Special case: When we would otherwise place the caret at the very end of the line fragment, we move it to the beginning of the next line fragment. This special case is implemented to align with UITextView. + let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation - 1) + if let lineFragment = lineFragmentNode.data.lineFragment, lineLocalLocation == lineFragment.range.upperBound { + let rect = caretRect(at: location + 1, placeCaretAtNextLineFragmentForLastCharacter: false) + return CGRect(x: leadingLineSpacing, y: rect.minY, width: rect.width, height: rect.height) + } + } + let localCaretRect = lineController.caretRect(atIndex: lineLocalLocation) let globalYPosition = line.yPosition + localCaretRect.minY let globalRect = CGRect(x: localCaretRect.minX, y: globalYPosition, width: localCaretRect.width, height: localCaretRect.height) return globalRect.offsetBy(dx: leadingLineSpacing, dy: textContainerInset.top) From 5cd3e286f357d25603a7f5a91f52004df5cbd8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 16:48:34 +0200 Subject: [PATCH 15/35] Treats line fragments as line endings --- .../TextInput/TextInputStringTokenizer.swift | 63 +++++++++++++++---- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift index fb97914b6..b63776f70 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift @@ -1,12 +1,14 @@ import UIKit final class TextInputStringTokenizer: UITextInputStringTokenizer { - private let lineManager: LineManager private let stringView: StringView + private let lineManager: LineManager + private let lineControllerStorage: LineControllerStorage - init(textInput: UIResponder & UITextInput, lineManager: LineManager, stringView: StringView) { + init(textInput: UIResponder & UITextInput, stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { self.lineManager = lineManager self.stringView = stringView + self.lineControllerStorage = lineControllerStorage super.init(textInput: textInput) } @@ -14,12 +16,17 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { guard let indexedPosition = position as? IndexedPosition else { return super.isPosition(position, atBoundary: granularity, inDirection: direction) } - if (granularity == .line || granularity == .paragraph), let line = lineManager.line(containingCharacterAt: indexedPosition.index) { - let localIndex = indexedPosition.index - line.location - if isBackward(direction) { - return localIndex == 0 + if granularity == .line || granularity == .paragraph { + if let location = lineBoundaryLocation(forLineContainingCharacterAt: indexedPosition.index, inDirection: direction) { + return indexedPosition.index == location } else { - return localIndex == line.data.length + return false + } + } else if granularity == .paragraph { + if let location = paragraphBoundaryLocation(forLineContainingCharacterAt: indexedPosition.index, inDirection: direction) { + return indexedPosition.index == location + } else { + return false } } else if granularity == .word, isCustomWordBoundry(at: indexedPosition.index) { return true @@ -40,12 +47,10 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { guard let indexedPosition = position as? IndexedPosition else { return super.position(from: position, toBoundary: granularity, inDirection: direction) } - if (granularity == .line || granularity == .paragraph), let line = lineManager.line(containingCharacterAt: indexedPosition.index) { - if isBackward(direction) { - return IndexedPosition(index: line.location) - } else { - return IndexedPosition(index: line.location + line.data.length) - } + if granularity == .line || granularity == .paragraph { + return lineBoundaryLocation(forLineContainingCharacterAt: indexedPosition.index, inDirection: direction).map(IndexedPosition.init) + } else if granularity == .paragraph { + return paragraphBoundaryLocation(forLineContainingCharacterAt: indexedPosition.index, inDirection: direction).map(IndexedPosition.init) } else { return super.position(from: position, toBoundary: granularity, inDirection: direction) } @@ -59,6 +64,38 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { } private extension TextInputStringTokenizer { + private func lineBoundaryLocation(forLineContainingCharacterAt sourceLocation: Int, inDirection direction: UITextDirection) -> Int? { + guard let line = lineManager.line(containingCharacterAt: sourceLocation) else { + return nil + } + guard let lineController = lineControllerStorage[line.id] else { + return nil + } + let lineLocalLocation = sourceLocation - line.location + let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) + guard let lineFragment = lineFragmentNode.data.lineFragment else { + return nil + } + if isBackward(direction) { + return lineFragment.range.lowerBound + } else if sourceLocation == lineFragment.range.lowerBound { + return lineFragmentNode.previous.data.lineFragment?.range.upperBound + } else { + return lineFragment.range.upperBound + } + } + + private func paragraphBoundaryLocation(forLineContainingCharacterAt sourceLocation: Int, inDirection direction: UITextDirection) -> Int? { + guard let line = lineManager.line(containingCharacterAt: sourceLocation) else { + return nil + } + if isBackward(direction) { + return line.location + } else { + return line.location + line.data.length + } + } + private func isCustomWordBoundry(at location: Int) -> Bool { guard let character = stringView.character(at: location) else { return false From 40b491dbc26e81ff213cb8af6614752fa423c4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 16:49:23 +0200 Subject: [PATCH 16/35] Calls caretRect(at:) on layoutManager instead of self --- Sources/Runestone/TextView/TextInput/TextInputView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/TextInput/TextInputView.swift b/Sources/Runestone/TextView/TextInput/TextInputView.swift index ca6449d6b..7749b28cd 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputView.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputView.swift @@ -1566,7 +1566,7 @@ extension TextInputView: IndentControllerDelegate { // MARK: - EditMenuControllerDelegate extension TextInputView: EditMenuControllerDelegate { func editMenuController(_ controller: EditMenuController, caretRectAt location: Int) -> CGRect { - return caretRect(at: location) + return layoutManager.caretRect(at: location) } func editMenuControllerShouldReplaceText(_ controller: EditMenuController) { From 78468cba6d8f9b27f48a1f43f25bf7be95966dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 16:50:08 +0200 Subject: [PATCH 17/35] Renames shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews --- .../Runestone/TextView/TextInput/TextInputView.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/TextInputView.swift b/Sources/Runestone/TextView/TextInput/TextInputView.swift index 7749b28cd..67f1c0c1e 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputView.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputView.swift @@ -41,7 +41,7 @@ final class TextInputView: UIView, UITextInput { shouldNotifyInputDelegate = true didCallPositionFromPositionInDirectionWithOffset = false } - shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews = !shouldNotifyInputDelegate + notifyInputDelegateAboutSelectionChangeInLayoutSubviews = !shouldNotifyInputDelegate if shouldNotifyInputDelegate { inputDelegate?.selectionWillChange(self) } @@ -556,8 +556,7 @@ final class TextInputView: UIView, UITextInput { } private var hasPendingFullLayout = false private let editMenuController = EditMenuController() - // swiftlint:disable:next identifier_name - private var shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews = false + private var notifyInputDelegateAboutSelectionChangeInLayoutSubviews = false private var didCallPositionFromPositionInDirectionWithOffset = false private var shouldPreserveUndoStackWhenSettingString = false @@ -627,7 +626,7 @@ final class TextInputView: UIView, UITextInput { // We notify the input delegate about selection changes in layoutSubviews so we have a chance of disabling notifying the input delegate during an editing operation. // We will sometimes disable notifying the input delegate when the user enters Korean text. // This workaround is inspired by a dialog with Alexander Blach (@lextar), developer of Textastic. - if shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews { + if notifyInputDelegateAboutSelectionChangeInLayoutSubviews { inputDelegate?.selectionWillChange(self) inputDelegate?.selectionDidChange(self) } @@ -1016,7 +1015,7 @@ extension TextInputView { return } // Disable notifying delegate in layout subviews to prevent issues when entering Korean text. This workaround is inspired by a dialog with Alexander Black (@lextar), developer of Textastic. - shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews = false + notifyInputDelegateAboutSelectionChangeInLayoutSubviews = false // Just before calling deleteBackward(), UIKit will set the selected range to a range of length 1, if the selected range has a length of 0. // In that case we want to undo to a selected range of length 0, so we construct our range here and pass it all the way to the undo operation. let selectedRangeAfterUndo: NSRange @@ -1314,7 +1313,7 @@ extension TextInputView { guard shouldChangeText(in: range, replacementText: markedText) else { return } - shouldNotifyInputDelegateAboutSelectionChangeInLayoutSubviews = true + notifyInputDelegateAboutSelectionChangeInLayoutSubviews = true markedRange = markedText.isEmpty ? nil : NSRange(location: range.location, length: markedText.utf16.count) replaceText(in: range, with: markedText) // The selected range passed to setMarkedText(_:selectedRange:) is local to the marked range. From 208f55169ba717101cb3fc54475673aed6117184 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 16:50:27 +0200 Subject: [PATCH 18/35] Renames shouldPreserveUndoStackWhenSettingString --- Sources/Runestone/TextView/TextInput/TextInputView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/TextInputView.swift b/Sources/Runestone/TextView/TextInput/TextInputView.swift index 67f1c0c1e..9cd429409 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputView.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputView.swift @@ -427,7 +427,7 @@ final class TextInputView: UIView, UITextInput { invalidateLines() layoutManager.setNeedsLayout() layoutManager.layoutIfNeeded() - if !shouldPreserveUndoStackWhenSettingString { + if !preserveUndoStackWhenSettingString { undoManager?.removeAllActions() } } @@ -558,7 +558,7 @@ final class TextInputView: UIView, UITextInput { private let editMenuController = EditMenuController() private var notifyInputDelegateAboutSelectionChangeInLayoutSubviews = false private var didCallPositionFromPositionInDirectionWithOffset = false - private var shouldPreserveUndoStackWhenSettingString = false + private var preserveUndoStackWhenSettingString = false // MARK: - Lifecycle init(theme: Theme) { @@ -1084,9 +1084,9 @@ extension TextInputView { } timedUndoManager.endUndoGrouping() let oldSelectedRange = selectedRange - shouldPreserveUndoStackWhenSettingString = true + preserveUndoStackWhenSettingString = true string = newString - shouldPreserveUndoStackWhenSettingString = false + preserveUndoStackWhenSettingString = false timedUndoManager.beginUndoGrouping() timedUndoManager.setActionName(L10n.Undo.ActionName.replaceAll) timedUndoManager.registerUndo(withTarget: self) { textInputView in From 18106b71aa63f36a21babc51304efe1b5f12db6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 16:51:02 +0200 Subject: [PATCH 19/35] Improves line movement --- .../TextInput/LineMovementController.swift | 83 ++++++++++++++--- .../TextView/TextInput/TextInputView.swift | 90 +++++++++++++++++-- 2 files changed, 152 insertions(+), 21 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/LineMovementController.swift b/Sources/Runestone/TextView/TextInput/LineMovementController.swift index fcaaa1fdd..446c03782 100644 --- a/Sources/Runestone/TextView/TextInput/LineMovementController.swift +++ b/Sources/Runestone/TextView/TextInput/LineMovementController.swift @@ -11,7 +11,10 @@ final class LineMovementController { self.lineControllerStorage = lineControllerStorage } - func location(from location: Int, in direction: UITextLayoutDirection, offset: Int) -> Int? { + func location(from location: Int, + in direction: UITextLayoutDirection, + offset: Int, + treatEndOfLineFragmentAsPreviousLineFragment: Bool = false) -> Int? { let newLocation: Int? switch direction { case .left: @@ -19,9 +22,13 @@ final class LineMovementController { case .right: newLocation = locationForMoving(fromLocation: location, by: offset) case .up: - newLocation = locationForMoving(lineOffset: offset * -1, fromLineContainingCharacterAt: location) + newLocation = locationForMoving(lineOffset: offset * -1, + fromLineContainingCharacterAt: location, + treatEndOfLineFragmentAsPreviousLineFragment: treatEndOfLineFragmentAsPreviousLineFragment) case .down: - newLocation = locationForMoving(lineOffset: offset, fromLineContainingCharacterAt: location) + newLocation = locationForMoving(lineOffset: offset, + fromLineContainingCharacterAt: location, + treatEndOfLineFragmentAsPreviousLineFragment: treatEndOfLineFragmentAsPreviousLineFragment) @unknown default: newLocation = nil } @@ -31,6 +38,37 @@ final class LineMovementController { return nil } } + + func locationForGoingToBeginningOfLine(movingFrom sourceLocation: Int, treatEndOfLineFragmentAsPreviousLineFragment: Bool) -> Int? { + guard let line = lineManager.line(containingCharacterAt: sourceLocation) else { + return nil + } + let lineFragmentNode = referenceLineFragmentNodeForGoingToBeginningOrEndOfLine( + containingCharacterAt: sourceLocation, + treatEndOfLineFragmentAsPreviousLineFragment: treatEndOfLineFragmentAsPreviousLineFragment) + guard let lineFragment = lineFragmentNode?.data.lineFragment else { + return nil + } + return line.location + lineFragment.range.lowerBound + } + + func locationForGoingToEndOfLine(movingFrom sourceLocation: Int, treatEndOfLineFragmentAsPreviousLineFragment: Bool) -> Int? { + guard let line = lineManager.line(containingCharacterAt: sourceLocation) else { + return nil + } + let lineFragmentNode = referenceLineFragmentNodeForGoingToBeginningOrEndOfLine( + containingCharacterAt: sourceLocation, + treatEndOfLineFragmentAsPreviousLineFragment: treatEndOfLineFragmentAsPreviousLineFragment) + guard let lineFragment = lineFragmentNode?.data.lineFragment else { + return nil + } + if lineFragment.range.upperBound == line.data.totalLength { + // Avoid navigating to after the delimiter for the line (e.g. \n) + return line.location + line.data.length + } else { + return line.location + lineFragment.range.upperBound + } + } } private extension LineMovementController { @@ -53,12 +91,18 @@ private extension LineMovementController { } } - private func locationForMoving(lineOffset: Int, fromLineContainingCharacterAt location: Int) -> Int { + private func locationForMoving(lineOffset: Int, + fromLineContainingCharacterAt location: Int, + treatEndOfLineFragmentAsPreviousLineFragment: Bool) -> Int { guard let line = lineManager.line(containingCharacterAt: location) else { return location } + guard let lineFragmentNode = referenceLineFragmentNodeForGoingToBeginningOrEndOfLine( + containingCharacterAt: location, + treatEndOfLineFragmentAsPreviousLineFragment: treatEndOfLineFragmentAsPreviousLineFragment) else { + return location + } let lineLocalLocation = max(min(location - line.location, line.data.totalLength), 0) - let lineFragmentNode = lineFragmentNode(containingCharacterAt: lineLocalLocation, in: line) let lineFragmentLocalLocation = lineLocalLocation - lineFragmentNode.location return locationForMoving(lineOffset: lineOffset, fromLocation: lineFragmentLocalLocation, inLineFragmentAt: lineFragmentNode.index, of: line) } @@ -73,7 +117,8 @@ private extension LineMovementController { return locationForMovingDownwards(lineOffset: lineOffset, fromLocation: location, inLineFragmentAt: lineFragmentIndex, of: line) } else { // lineOffset is 0 so we shouldn't change the line - let destinationLineFragmentNode = lineFragmentNode(atIndex: lineFragmentIndex, in: line) + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + let destinationLineFragmentNode = lineController.lineFragmentNode(atIndex: lineFragmentIndex) let lineLocation = line.location let preferredLocation = lineLocation + destinationLineFragmentNode.location + location let lineFragmentMaximumLocation = lineLocation + destinationLineFragmentNode.location + destinationLineFragmentNode.value @@ -130,13 +175,23 @@ private extension LineMovementController { return lineController.numberOfLineFragments } - private func lineFragmentNode(atIndex index: Int, in line: DocumentLineNode) -> LineFragmentNode { - let lineController = lineControllerStorage.getOrCreateLineController(for: line) - return lineController.lineFragmentNode(atIndex: index) - } - - private func lineFragmentNode(containingCharacterAt location: Int, in line: DocumentLineNode) -> LineFragmentNode { - let lineController = lineControllerStorage.getOrCreateLineController(for: line) - return lineController.lineFragmentNode(containingCharacterAt: location) + private func referenceLineFragmentNodeForGoingToBeginningOrEndOfLine(containingCharacterAt location: Int, + treatEndOfLineFragmentAsPreviousLineFragment: Bool) -> LineFragmentNode? { + guard let line = lineManager.line(containingCharacterAt: location) else { + return nil + } + guard let lineController = lineControllerStorage[line.id] else { + return nil + } + let lineLocalLocation = location - line.location + let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) + guard let lineFragment = lineFragmentNode.data.lineFragment else { + return nil + } + if treatEndOfLineFragmentAsPreviousLineFragment, location == lineFragment.range.lowerBound, lineFragmentNode.index > 0 { + return lineController.lineFragmentNode(atIndex: lineFragmentNode.index - 1) + } else { + return lineFragmentNode + } } } diff --git a/Sources/Runestone/TextView/TextInput/TextInputView.swift b/Sources/Runestone/TextView/TextInput/TextInputView.swift index 9cd429409..5bc3b0a4a 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputView.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputView.swift @@ -76,10 +76,10 @@ final class TextInputView: UIView, UITextInput { var hasText: Bool { return string.length > 0 } - private(set) lazy var tokenizer: UITextInputTokenizer = TextInputStringTokenizer( - textInput: self, - lineManager: lineManager, - stringView: stringView) + private(set) lazy var tokenizer: UITextInputTokenizer = TextInputStringTokenizer(textInput: self, + stringView: stringView, + lineManager: lineManager, + lineControllerStorage: lineControllerStorage) var autocorrectionType: UITextAutocorrectionType = .default var autocapitalizationType: UITextAutocapitalizationType = .sentences var smartQuotesType: UITextSmartQuotesType = .default @@ -120,6 +120,15 @@ final class TextInputView: UIView, UITextInput { override var undoManager: UndoManager? { return timedUndoManager } + override var keyCommands: [UIKeyCommand]? { + let goToLineBeginningCommand = UIKeyCommand(input: "a", modifierFlags: .control, action: #selector(goToLineBeginning)) + let goToLineEndCommand = UIKeyCommand(input: "e", modifierFlags: .control, action: #selector(goToLineEnd)) + if #available(iOS 15.0, *) { + goToLineBeginningCommand.wantsPriorityOverSystemBehavior = true + goToLineEndCommand.wantsPriorityOverSystemBehavior = true + } + return [goToLineBeginningCommand, goToLineEndCommand] + } // MARK: - Appearance var theme: Theme { @@ -559,6 +568,8 @@ final class TextInputView: UIView, UITextInput { private var notifyInputDelegateAboutSelectionChangeInLayoutSubviews = false private var didCallPositionFromPositionInDirectionWithOffset = false private var preserveUndoStackWhenSettingString = false + private var placeCaretAtNextLineFragmentForLastCharacter = true + private var didPlaceCaretAtNextLineFragmentForLastCharacter = false // MARK: - Lifecycle init(theme: Theme) { @@ -693,6 +704,8 @@ final class TextInputView: UIView, UITextInput { } else { return false } + } else if action == #selector(goToLineBeginning) || action == #selector(goToLineEnd) { + return selectedRange != nil } else { return super.canPerformAction(action, withSender: sender) } @@ -838,6 +851,35 @@ final class TextInputView: UIView, UITextInput { // MARK: - Navigation private extension TextInputView { + @objc private func goToLineBeginning() { + guard let selectedRange = selectedRange else { + return + } + guard let location = lineMovementController.locationForGoingToBeginningOfLine( + movingFrom: selectedRange.upperBound, + treatEndOfLineFragmentAsPreviousLineFragment: !didPlaceCaretAtNextLineFragmentForLastCharacter) else { + return + } + inputDelegate?.selectionWillChange(self) + _selectedRange = NSRange(location: location, length: 0) + inputDelegate?.selectionDidChange(self) + } + + @objc private func goToLineEnd() { + guard let selectedRange = selectedRange else { + return + } + guard let location = lineMovementController.locationForGoingToEndOfLine( + movingFrom: selectedRange.upperBound, + treatEndOfLineFragmentAsPreviousLineFragment: !didPlaceCaretAtNextLineFragmentForLastCharacter) else { + return + } + placeCaretAtNextLineFragmentForLastCharacter = false + inputDelegate?.selectionWillChange(self) + _selectedRange = NSRange(location: location, length: 0) + inputDelegate?.selectionDidChange(self) + } + private func handleKeyPressDuringMultistageTextInput(keyCode: UIKeyboardHIDUsage) { // When editing multistage text input (that is, we have a marked text) we let the user unmark the text // by pressing the arrow keys or Escape. This isn't common in iOS apps but it's the default behavior @@ -879,6 +921,30 @@ private extension TextInputView { } } } + + private func shouldPlaceCaretAtNextLineFragmentForLastCharacter(forNavigationInDirection direction: UITextLayoutDirection, + fromLocation location: Int) -> Bool { + + guard direction == .up || direction == .down else { + return true + } + guard didPlaceCaretAtNextLineFragmentForLastCharacter else { + // The previous rect wasn't placed at the next line fragment so we don't do it this time either. This handles the case where: + // 1. The user navigates to the end of a line using CTRL+E in a line made up of multiple line fragments. + // 2. The user presses downwards. + // The caret should then be placed at the end of the line fragment instead. + return false + } + guard let line = lineManager.line(containingCharacterAt: location) else { + return true + } + guard let lineController = lineControllerStorage[line.id] else { + return true + } + let lineLocalLocation = location - line.location + let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) + return location == lineFragmentNode.data.lineFragment?.range.location + } } // MARK: - Layout @@ -962,7 +1028,11 @@ extension TextInputView { guard let indexedPosition = position as? IndexedPosition else { fatalError("Expected position to be of type \(IndexedPosition.self)") } - return caretRect(at: indexedPosition.index) + let localPlaceCaretAtNextLineFragmentForLastCharacter = placeCaretAtNextLineFragmentForLastCharacter + didPlaceCaretAtNextLineFragmentForLastCharacter = localPlaceCaretAtNextLineFragmentForLastCharacter + placeCaretAtNextLineFragmentForLastCharacter = true + return layoutManager.caretRect(at: indexedPosition.index, + placeCaretAtNextLineFragmentForLastCharacter: localPlaceCaretAtNextLineFragmentForLastCharacter) } func caretRect(at location: Int) -> CGRect { @@ -1352,10 +1422,16 @@ extension TextInputView { return nil } didCallPositionFromPositionInDirectionWithOffset = true - guard let location = lineMovementController.location(from: indexedPosition.index, in: direction, offset: offset) else { + guard let newLocation = lineMovementController.location( + from: indexedPosition.index, + in: direction, + offset: offset, + treatEndOfLineFragmentAsPreviousLineFragment: !didPlaceCaretAtNextLineFragmentForLastCharacter) else { return nil } - return IndexedPosition(index: location) + placeCaretAtNextLineFragmentForLastCharacter = shouldPlaceCaretAtNextLineFragmentForLastCharacter(forNavigationInDirection: direction, + fromLocation: indexedPosition.index) + return IndexedPosition(index: newLocation) } func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { From b0fcea52d4803a348293998926e751be5ce8b37d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 27 Aug 2022 17:13:13 +0200 Subject: [PATCH 20/35] Fixes incorrect selection --- Sources/Runestone/TextView/TextInput/LayoutManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/LayoutManager.swift b/Sources/Runestone/TextView/TextInput/LayoutManager.swift index 5e76410b0..c264ffa94 100644 --- a/Sources/Runestone/TextView/TextInput/LayoutManager.swift +++ b/Sources/Runestone/TextView/TextInput/LayoutManager.swift @@ -458,8 +458,8 @@ extension LayoutManager { } let selectsLineEnding = range.upperBound == endLine.location let adjustedRange = NSRange(location: range.location, length: selectsLineEnding ? range.length - 1 : range.length) - let startCaretRect = caretRect(at: adjustedRange.lowerBound) - let endCaretRect = caretRect(at: adjustedRange.upperBound) + let startCaretRect = caretRect(at: adjustedRange.lowerBound, placeCaretAtNextLineFragmentForLastCharacter: true) + let endCaretRect = caretRect(at: adjustedRange.upperBound, placeCaretAtNextLineFragmentForLastCharacter: false) let fullWidth = max(contentWidth, scrollViewWidth) - textContainerInset.right if startCaretRect.minY == endCaretRect.minY && startCaretRect.maxY == endCaretRect.maxY { // Selecting text in the same line fragment. From c782725298e29d1077af5baa411b51e2127516b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 28 Aug 2022 11:29:46 +0200 Subject: [PATCH 21/35] Differentiates between line and paragraph --- .../TextView/TextInput/TextInputStringTokenizer.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift index b63776f70..d815b4198 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift @@ -16,7 +16,7 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { guard let indexedPosition = position as? IndexedPosition else { return super.isPosition(position, atBoundary: granularity, inDirection: direction) } - if granularity == .line || granularity == .paragraph { + if granularity == .line { if let location = lineBoundaryLocation(forLineContainingCharacterAt: indexedPosition.index, inDirection: direction) { return indexedPosition.index == location } else { @@ -47,7 +47,7 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { guard let indexedPosition = position as? IndexedPosition else { return super.position(from: position, toBoundary: granularity, inDirection: direction) } - if granularity == .line || granularity == .paragraph { + if granularity == .line { return lineBoundaryLocation(forLineContainingCharacterAt: indexedPosition.index, inDirection: direction).map(IndexedPosition.init) } else if granularity == .paragraph { return paragraphBoundaryLocation(forLineContainingCharacterAt: indexedPosition.index, inDirection: direction).map(IndexedPosition.init) From 36f5d4fbe8ea57832f1930d6ae62611839fe3dd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 28 Aug 2022 17:24:35 +0200 Subject: [PATCH 22/35] Removes Ctrl+A and Ctrl+E key commands --- .../TextInput/LineMovementController.swift | 31 -------------- .../TextView/TextInput/TextInputView.swift | 40 ------------------- 2 files changed, 71 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/LineMovementController.swift b/Sources/Runestone/TextView/TextInput/LineMovementController.swift index 446c03782..aaecf9a0d 100644 --- a/Sources/Runestone/TextView/TextInput/LineMovementController.swift +++ b/Sources/Runestone/TextView/TextInput/LineMovementController.swift @@ -38,37 +38,6 @@ final class LineMovementController { return nil } } - - func locationForGoingToBeginningOfLine(movingFrom sourceLocation: Int, treatEndOfLineFragmentAsPreviousLineFragment: Bool) -> Int? { - guard let line = lineManager.line(containingCharacterAt: sourceLocation) else { - return nil - } - let lineFragmentNode = referenceLineFragmentNodeForGoingToBeginningOrEndOfLine( - containingCharacterAt: sourceLocation, - treatEndOfLineFragmentAsPreviousLineFragment: treatEndOfLineFragmentAsPreviousLineFragment) - guard let lineFragment = lineFragmentNode?.data.lineFragment else { - return nil - } - return line.location + lineFragment.range.lowerBound - } - - func locationForGoingToEndOfLine(movingFrom sourceLocation: Int, treatEndOfLineFragmentAsPreviousLineFragment: Bool) -> Int? { - guard let line = lineManager.line(containingCharacterAt: sourceLocation) else { - return nil - } - let lineFragmentNode = referenceLineFragmentNodeForGoingToBeginningOrEndOfLine( - containingCharacterAt: sourceLocation, - treatEndOfLineFragmentAsPreviousLineFragment: treatEndOfLineFragmentAsPreviousLineFragment) - guard let lineFragment = lineFragmentNode?.data.lineFragment else { - return nil - } - if lineFragment.range.upperBound == line.data.totalLength { - // Avoid navigating to after the delimiter for the line (e.g. \n) - return line.location + line.data.length - } else { - return line.location + lineFragment.range.upperBound - } - } } private extension LineMovementController { diff --git a/Sources/Runestone/TextView/TextInput/TextInputView.swift b/Sources/Runestone/TextView/TextInput/TextInputView.swift index 5bc3b0a4a..bb9816881 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputView.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputView.swift @@ -120,15 +120,6 @@ final class TextInputView: UIView, UITextInput { override var undoManager: UndoManager? { return timedUndoManager } - override var keyCommands: [UIKeyCommand]? { - let goToLineBeginningCommand = UIKeyCommand(input: "a", modifierFlags: .control, action: #selector(goToLineBeginning)) - let goToLineEndCommand = UIKeyCommand(input: "e", modifierFlags: .control, action: #selector(goToLineEnd)) - if #available(iOS 15.0, *) { - goToLineBeginningCommand.wantsPriorityOverSystemBehavior = true - goToLineEndCommand.wantsPriorityOverSystemBehavior = true - } - return [goToLineBeginningCommand, goToLineEndCommand] - } // MARK: - Appearance var theme: Theme { @@ -704,8 +695,6 @@ final class TextInputView: UIView, UITextInput { } else { return false } - } else if action == #selector(goToLineBeginning) || action == #selector(goToLineEnd) { - return selectedRange != nil } else { return super.canPerformAction(action, withSender: sender) } @@ -851,35 +840,6 @@ final class TextInputView: UIView, UITextInput { // MARK: - Navigation private extension TextInputView { - @objc private func goToLineBeginning() { - guard let selectedRange = selectedRange else { - return - } - guard let location = lineMovementController.locationForGoingToBeginningOfLine( - movingFrom: selectedRange.upperBound, - treatEndOfLineFragmentAsPreviousLineFragment: !didPlaceCaretAtNextLineFragmentForLastCharacter) else { - return - } - inputDelegate?.selectionWillChange(self) - _selectedRange = NSRange(location: location, length: 0) - inputDelegate?.selectionDidChange(self) - } - - @objc private func goToLineEnd() { - guard let selectedRange = selectedRange else { - return - } - guard let location = lineMovementController.locationForGoingToEndOfLine( - movingFrom: selectedRange.upperBound, - treatEndOfLineFragmentAsPreviousLineFragment: !didPlaceCaretAtNextLineFragmentForLastCharacter) else { - return - } - placeCaretAtNextLineFragmentForLastCharacter = false - inputDelegate?.selectionWillChange(self) - _selectedRange = NSRange(location: location, length: 0) - inputDelegate?.selectionDidChange(self) - } - private func handleKeyPressDuringMultistageTextInput(keyCode: UIKeyboardHIDUsage) { // When editing multistage text input (that is, we have a marked text) we let the user unmark the text // by pressing the arrow keys or Escape. This isn't common in iOS apps but it's the default behavior From 1e76fad5b9fbfb63d1d9291cf4a010a16cf1fabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 28 Aug 2022 17:25:43 +0200 Subject: [PATCH 23/35] Removes special handling of caret on next line fragment when otherwise after last character --- .../TextView/TextInput/LayoutManager.swift | 26 ++++++------ .../TextInput/LineMovementController.swift | 42 +++---------------- .../TextView/TextInput/TextInputView.swift | 40 +----------------- 3 files changed, 20 insertions(+), 88 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/LayoutManager.swift b/Sources/Runestone/TextView/TextInput/LayoutManager.swift index c264ffa94..33c0f8a98 100644 --- a/Sources/Runestone/TextView/TextInput/LayoutManager.swift +++ b/Sources/Runestone/TextView/TextInput/LayoutManager.swift @@ -416,23 +416,21 @@ final class LayoutManager { // MARK: - UITextInput extension LayoutManager { - func caretRect(at location: Int, placeCaretAtNextLineFragmentForLastCharacter: Bool = false) -> CGRect { + func caretRect(at location: Int) -> CGRect { let safeLocation = min(max(location, 0), stringView.string.length) let line = lineManager.line(containingCharacterAt: safeLocation)! let lineController = lineControllerStorage.getOrCreateLineController(for: line) let lineLocalLocation = safeLocation - line.location - if placeCaretAtNextLineFragmentForLastCharacter && lineLocalLocation > 0 && lineLocalLocation < line.data.totalLength { - // Special case: When we would otherwise place the caret at the very end of the line fragment, we move it to the beginning of the next line fragment. This special case is implemented to align with UITextView. - let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation - 1) - if let lineFragment = lineFragmentNode.data.lineFragment, lineLocalLocation == lineFragment.range.upperBound { - let rect = caretRect(at: location + 1, placeCaretAtNextLineFragmentForLastCharacter: false) - return CGRect(x: leadingLineSpacing, y: rect.minY, width: rect.width, height: rect.height) - } + let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) + if lineFragmentNode.index > 0, let lineFragment = lineFragmentNode.data.lineFragment, location == lineFragment.range.location { + let rect = caretRect(at: location + 1) + return CGRect(x: leadingLineSpacing, y: rect.minY, width: rect.width, height: rect.height) + } else { + let localCaretRect = lineController.caretRect(atIndex: lineLocalLocation) + let globalYPosition = line.yPosition + localCaretRect.minY + let globalRect = CGRect(x: localCaretRect.minX, y: globalYPosition, width: localCaretRect.width, height: localCaretRect.height) + return globalRect.offsetBy(dx: leadingLineSpacing, dy: textContainerInset.top) } - let localCaretRect = lineController.caretRect(atIndex: lineLocalLocation) - let globalYPosition = line.yPosition + localCaretRect.minY - let globalRect = CGRect(x: localCaretRect.minX, y: globalYPosition, width: localCaretRect.width, height: localCaretRect.height) - return globalRect.offsetBy(dx: leadingLineSpacing, dy: textContainerInset.top) } func firstRect(for range: NSRange) -> CGRect { @@ -458,8 +456,8 @@ extension LayoutManager { } let selectsLineEnding = range.upperBound == endLine.location let adjustedRange = NSRange(location: range.location, length: selectsLineEnding ? range.length - 1 : range.length) - let startCaretRect = caretRect(at: adjustedRange.lowerBound, placeCaretAtNextLineFragmentForLastCharacter: true) - let endCaretRect = caretRect(at: adjustedRange.upperBound, placeCaretAtNextLineFragmentForLastCharacter: false) + let startCaretRect = caretRect(at: adjustedRange.lowerBound) + let endCaretRect = caretRect(at: adjustedRange.upperBound) let fullWidth = max(contentWidth, scrollViewWidth) - textContainerInset.right if startCaretRect.minY == endCaretRect.minY && startCaretRect.maxY == endCaretRect.maxY { // Selecting text in the same line fragment. diff --git a/Sources/Runestone/TextView/TextInput/LineMovementController.swift b/Sources/Runestone/TextView/TextInput/LineMovementController.swift index aaecf9a0d..05fbdac5f 100644 --- a/Sources/Runestone/TextView/TextInput/LineMovementController.swift +++ b/Sources/Runestone/TextView/TextInput/LineMovementController.swift @@ -11,10 +11,7 @@ final class LineMovementController { self.lineControllerStorage = lineControllerStorage } - func location(from location: Int, - in direction: UITextLayoutDirection, - offset: Int, - treatEndOfLineFragmentAsPreviousLineFragment: Bool = false) -> Int? { + func location(from location: Int,in direction: UITextLayoutDirection, offset: Int) -> Int? { let newLocation: Int? switch direction { case .left: @@ -22,13 +19,9 @@ final class LineMovementController { case .right: newLocation = locationForMoving(fromLocation: location, by: offset) case .up: - newLocation = locationForMoving(lineOffset: offset * -1, - fromLineContainingCharacterAt: location, - treatEndOfLineFragmentAsPreviousLineFragment: treatEndOfLineFragmentAsPreviousLineFragment) + newLocation = locationForMoving(lineOffset: offset * -1, fromLineContainingCharacterAt: location) case .down: - newLocation = locationForMoving(lineOffset: offset, - fromLineContainingCharacterAt: location, - treatEndOfLineFragmentAsPreviousLineFragment: treatEndOfLineFragmentAsPreviousLineFragment) + newLocation = locationForMoving(lineOffset: offset, fromLineContainingCharacterAt: location) @unknown default: newLocation = nil } @@ -60,18 +53,15 @@ private extension LineMovementController { } } - private func locationForMoving(lineOffset: Int, - fromLineContainingCharacterAt location: Int, - treatEndOfLineFragmentAsPreviousLineFragment: Bool) -> Int { + private func locationForMoving(lineOffset: Int, fromLineContainingCharacterAt location: Int) -> Int { guard let line = lineManager.line(containingCharacterAt: location) else { return location } - guard let lineFragmentNode = referenceLineFragmentNodeForGoingToBeginningOrEndOfLine( - containingCharacterAt: location, - treatEndOfLineFragmentAsPreviousLineFragment: treatEndOfLineFragmentAsPreviousLineFragment) else { + guard let lineController = lineControllerStorage[line.id] else { return location } let lineLocalLocation = max(min(location - line.location, line.data.totalLength), 0) + let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) let lineFragmentLocalLocation = lineLocalLocation - lineFragmentNode.location return locationForMoving(lineOffset: lineOffset, fromLocation: lineFragmentLocalLocation, inLineFragmentAt: lineFragmentNode.index, of: line) } @@ -143,24 +133,4 @@ private extension LineMovementController { let lineController = lineControllerStorage.getOrCreateLineController(for: line) return lineController.numberOfLineFragments } - - private func referenceLineFragmentNodeForGoingToBeginningOrEndOfLine(containingCharacterAt location: Int, - treatEndOfLineFragmentAsPreviousLineFragment: Bool) -> LineFragmentNode? { - guard let line = lineManager.line(containingCharacterAt: location) else { - return nil - } - guard let lineController = lineControllerStorage[line.id] else { - return nil - } - let lineLocalLocation = location - line.location - let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) - guard let lineFragment = lineFragmentNode.data.lineFragment else { - return nil - } - if treatEndOfLineFragmentAsPreviousLineFragment, location == lineFragment.range.lowerBound, lineFragmentNode.index > 0 { - return lineController.lineFragmentNode(atIndex: lineFragmentNode.index - 1) - } else { - return lineFragmentNode - } - } } diff --git a/Sources/Runestone/TextView/TextInput/TextInputView.swift b/Sources/Runestone/TextView/TextInput/TextInputView.swift index bb9816881..17fb43cbb 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputView.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputView.swift @@ -559,8 +559,6 @@ final class TextInputView: UIView, UITextInput { private var notifyInputDelegateAboutSelectionChangeInLayoutSubviews = false private var didCallPositionFromPositionInDirectionWithOffset = false private var preserveUndoStackWhenSettingString = false - private var placeCaretAtNextLineFragmentForLastCharacter = true - private var didPlaceCaretAtNextLineFragmentForLastCharacter = false // MARK: - Lifecycle init(theme: Theme) { @@ -881,30 +879,6 @@ private extension TextInputView { } } } - - private func shouldPlaceCaretAtNextLineFragmentForLastCharacter(forNavigationInDirection direction: UITextLayoutDirection, - fromLocation location: Int) -> Bool { - - guard direction == .up || direction == .down else { - return true - } - guard didPlaceCaretAtNextLineFragmentForLastCharacter else { - // The previous rect wasn't placed at the next line fragment so we don't do it this time either. This handles the case where: - // 1. The user navigates to the end of a line using CTRL+E in a line made up of multiple line fragments. - // 2. The user presses downwards. - // The caret should then be placed at the end of the line fragment instead. - return false - } - guard let line = lineManager.line(containingCharacterAt: location) else { - return true - } - guard let lineController = lineControllerStorage[line.id] else { - return true - } - let lineLocalLocation = location - line.location - let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) - return location == lineFragmentNode.data.lineFragment?.range.location - } } // MARK: - Layout @@ -988,11 +962,7 @@ extension TextInputView { guard let indexedPosition = position as? IndexedPosition else { fatalError("Expected position to be of type \(IndexedPosition.self)") } - let localPlaceCaretAtNextLineFragmentForLastCharacter = placeCaretAtNextLineFragmentForLastCharacter - didPlaceCaretAtNextLineFragmentForLastCharacter = localPlaceCaretAtNextLineFragmentForLastCharacter - placeCaretAtNextLineFragmentForLastCharacter = true - return layoutManager.caretRect(at: indexedPosition.index, - placeCaretAtNextLineFragmentForLastCharacter: localPlaceCaretAtNextLineFragmentForLastCharacter) + return layoutManager.caretRect(at: indexedPosition.index) } func caretRect(at location: Int) -> CGRect { @@ -1382,15 +1352,9 @@ extension TextInputView { return nil } didCallPositionFromPositionInDirectionWithOffset = true - guard let newLocation = lineMovementController.location( - from: indexedPosition.index, - in: direction, - offset: offset, - treatEndOfLineFragmentAsPreviousLineFragment: !didPlaceCaretAtNextLineFragmentForLastCharacter) else { + guard let newLocation = lineMovementController.location(from: indexedPosition.index, in: direction, offset: offset) else { return nil } - placeCaretAtNextLineFragmentForLastCharacter = shouldPlaceCaretAtNextLineFragmentForLastCharacter(forNavigationInDirection: direction, - fromLocation: indexedPosition.index) return IndexedPosition(index: newLocation) } From b77f951f33b0ff351edf0bd05884913dca0d9e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 28 Aug 2022 17:26:31 +0200 Subject: [PATCH 24/35] Supports platform-specific behavior for keyboard navigation --- .../TextInput/TextInputStringTokenizer.swift | 239 ++++++++++++++---- 1 file changed, 191 insertions(+), 48 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift index d815b4198..15d075457 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift @@ -4,6 +4,9 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { private let stringView: StringView private let lineManager: LineManager private let lineControllerStorage: LineControllerStorage + private var newlineCharacters: [Character] { + return [Symbol.Character.lineFeed, Symbol.Character.carriageReturn, Symbol.Character.carriageReturnLineFeed] + } init(textInput: UIResponder & UITextInput, stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { self.lineManager = lineManager @@ -13,98 +16,238 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { } override func isPosition(_ position: UITextPosition, atBoundary granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool { - guard let indexedPosition = position as? IndexedPosition else { - return super.isPosition(position, atBoundary: granularity, inDirection: direction) - } if granularity == .line { - if let location = lineBoundaryLocation(forLineContainingCharacterAt: indexedPosition.index, inDirection: direction) { - return indexedPosition.index == location - } else { - return false - } + return isPosition(position, atLineBoundaryInDirection: direction) } else if granularity == .paragraph { - if let location = paragraphBoundaryLocation(forLineContainingCharacterAt: indexedPosition.index, inDirection: direction) { - return indexedPosition.index == location - } else { - return false - } - } else if granularity == .word, isCustomWordBoundry(at: indexedPosition.index) { - return true + return isPosition(position, atParagraphBoundaryInDirection: direction) + } else if granularity == .word { + return isPosition(position, atWordBoundaryInDirection: direction) } else { return super.isPosition(position, atBoundary: granularity, inDirection: direction) } } - override func isPosition(_ position: UITextPosition, - withinTextUnit granularity: UITextGranularity, - inDirection direction: UITextDirection) -> Bool { + override func isPosition(_ position: UITextPosition, withinTextUnit granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool { return super.isPosition(position, withinTextUnit: granularity, inDirection: direction) } - override func position(from position: UITextPosition, - toBoundary granularity: UITextGranularity, - inDirection direction: UITextDirection) -> UITextPosition? { - guard let indexedPosition = position as? IndexedPosition else { - return super.position(from: position, toBoundary: granularity, inDirection: direction) - } + override func position(from position: UITextPosition, toBoundary granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextPosition? { if granularity == .line { - return lineBoundaryLocation(forLineContainingCharacterAt: indexedPosition.index, inDirection: direction).map(IndexedPosition.init) + return self.position(from: position, toLineBoundaryInDirection: direction) } else if granularity == .paragraph { - return paragraphBoundaryLocation(forLineContainingCharacterAt: indexedPosition.index, inDirection: direction).map(IndexedPosition.init) + return self.position(from: position, toParagraphBoundaryInDirection: direction) + } else if granularity == .word { + return self.position(from: position, toWordBoundaryInDirection: direction) } else { return super.position(from: position, toBoundary: granularity, inDirection: direction) } } - override func rangeEnclosingPosition(_ position: UITextPosition, - with granularity: UITextGranularity, - inDirection direction: UITextDirection) -> UITextRange? { + override func rangeEnclosingPosition(_ position: UITextPosition, with granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextRange? { return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) } } +// MARK: - Lines private extension TextInputStringTokenizer { - private func lineBoundaryLocation(forLineContainingCharacterAt sourceLocation: Int, inDirection direction: UITextDirection) -> Int? { - guard let line = lineManager.line(containingCharacterAt: sourceLocation) else { + private func isPosition(_ position: UITextPosition, atLineBoundaryInDirection direction: UITextDirection) -> Bool { + // I can't seem to make Ctrl+A, Ctrl+E, Cmd+Left, and Cmd+Right work properly if this function returns anything but false. + // I've tried various ways of determining the line boundary but UIKit doesn't seem to be happy with anything I come up with ultimately leading to incorrect keyboard navigation. I haven't yet found any drawbacks to returning false in all cases. + return false + } + + private func position(from position: UITextPosition, toLineBoundaryInDirection direction: UITextDirection) -> UITextPosition? { + guard let indexedPosition = position as? IndexedPosition else { + return nil + } + let location = indexedPosition.index + guard let line = lineManager.line(containingCharacterAt: location) else { return nil } guard let lineController = lineControllerStorage[line.id] else { return nil } - let lineLocalLocation = sourceLocation - line.location + let lineLocation = line.location + let lineLocalLocation = location - lineLocation let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) guard let lineFragment = lineFragmentNode.data.lineFragment else { return nil } - if isBackward(direction) { - return lineFragment.range.lowerBound - } else if sourceLocation == lineFragment.range.lowerBound { - return lineFragmentNode.previous.data.lineFragment?.range.upperBound + if direction.isForward { + if location == stringView.string.length { + return position + } else { + let preferredLocation = lineLocation + lineFragment.range.upperBound + let lineEndLocation = lineLocation + line.data.totalLength + if preferredLocation == lineEndLocation { + // Navigate to end of line but before the delimiter (\n etc.) + return IndexedPosition(index: preferredLocation - line.data.delimiterLength) + } else { + // Navigate to the end of the line but before the last character. This is a hack that avoids an issue where the caret is placed on the next line. The approach seems to be similar to what Textastic is doing. + let lastCharacterRange = stringView.string.customRangeOfComposedCharacterSequence(at: lineFragment.range.upperBound) + return IndexedPosition(index: lineLocation + lineFragment.range.upperBound - lastCharacterRange.length) + } + } } else { - return lineFragment.range.upperBound + if location == 0 { + return position + } else { + return IndexedPosition(index: lineLocation + lineFragment.range.lowerBound) + } } } +} - private func paragraphBoundaryLocation(forLineContainingCharacterAt sourceLocation: Int, inDirection direction: UITextDirection) -> Int? { - guard let line = lineManager.line(containingCharacterAt: sourceLocation) else { +// MARK: - Paragraphs +private extension TextInputStringTokenizer { + private func isPosition(_ position: UITextPosition, atParagraphBoundaryInDirection direction: UITextDirection) -> Bool { + // I can't seem to make Ctrl+A, Ctrl+E, Cmd+Left, and Cmd+Right work properly if this function returns anything but false. + // I've tried various ways of determining the paragraph boundary but UIKit doesn't seem to be happy with anything I come up with ultimately leading to incorrect keyboard navigation. I haven't yet found any drawbacks to returning false in all cases. + return false + } + + private func position(from position: UITextPosition, toParagraphBoundaryInDirection direction: UITextDirection) -> UITextPosition? { + guard let indexedPosition = position as? IndexedPosition else { return nil } - if isBackward(direction) { - return line.location + let location = indexedPosition.index + if direction.isForward { + if location == stringView.string.length { + return position + } else { + var currentIndex = location + while currentIndex < stringView.string.length { + guard let currentCharacter = stringView.character(at: currentIndex) else { + break + } + if newlineCharacters.contains(currentCharacter) { + break + } + currentIndex += 1 + } + return IndexedPosition(index: currentIndex) + } } else { - return line.location + line.data.length + if location == 0 { + return position + } else { + var currentIndex = location - 1 + while currentIndex > 0 { + guard let currentCharacter = stringView.character(at: currentIndex) else { + break + } + if newlineCharacters.contains(currentCharacter) { + currentIndex += 1 + break + } + currentIndex -= 1 + } + return IndexedPosition(index: currentIndex) + } } } +} - private func isCustomWordBoundry(at location: Int) -> Bool { - guard let character = stringView.character(at: location) else { +// MARK: - Words +private extension TextInputStringTokenizer { + private func isPosition(_ position: UITextPosition, atWordBoundaryInDirection direction: UITextDirection) -> Bool { + guard let indexedPosition = position as? IndexedPosition else { return false } - let wordBoundryCharacterSet: CharacterSet = .punctuationCharacters - return character.unicodeScalars.allSatisfy { wordBoundryCharacterSet.contains($0) } + let location = indexedPosition.index + let alphanumerics = CharacterSet.alphanumerics + if direction.isForward { + if location == 0 { + return false + } else if let previousCharacter = stringView.character(at: location - 1) { + if location == stringView.string.length { + return alphanumerics.contains(previousCharacter) + } else if let character = stringView.character(at: location) { + return alphanumerics.contains(previousCharacter) && !alphanumerics.contains(character) + } else { + return false + } + } else { + return false + } + } else { + if location == stringView.string.length { + return false + } else if let character = stringView.character(at: location) { + if location == 0 { + return alphanumerics.contains(character) + } else if let previousCharacter = stringView.character(at: location - 1) { + return alphanumerics.contains(character) && !alphanumerics.contains(previousCharacter) + } else { + return false + } + } else { + return false + } + } } - private func isBackward(_ direction: UITextDirection) -> Bool { - return direction.rawValue == UITextStorageDirection.backward.rawValue || direction.rawValue == UITextLayoutDirection.left.rawValue + private func position(from position: UITextPosition, toWordBoundaryInDirection direction: UITextDirection) -> UITextPosition? { + guard let indexedPosition = position as? IndexedPosition else { + return nil + } + let location = indexedPosition.index + let alphanumerics = CharacterSet.alphanumerics + if direction.isForward { + if location == stringView.string.length { + return position + } else if let referenceCharacter = stringView.character(at: location) { + let isReferenceCharacterAlphanumeric = alphanumerics.contains(referenceCharacter) + var currentIndex = location + 1 + while currentIndex < stringView.string.length { + guard let currentCharacter = stringView.character(at: currentIndex) else { + break + } + let isCurrentCharacterAlphanumeric = alphanumerics.contains(currentCharacter) + if isReferenceCharacterAlphanumeric != isCurrentCharacterAlphanumeric { + break + } + currentIndex += 1 + } + return IndexedPosition(index: currentIndex) + } else { + return nil + } + } else { + if location == 0 { + return position + } else if let referenceCharacter = stringView.character(at: location - 1) { + let isReferenceCharacterAlphanumeric = alphanumerics.contains(referenceCharacter) + var currentIndex = location - 1 + while currentIndex > 0 { + guard let currentCharacter = stringView.character(at: currentIndex) else { + break + } + let isCurrentCharacterAlphanumeric = alphanumerics.contains(currentCharacter) + if isReferenceCharacterAlphanumeric != isCurrentCharacterAlphanumeric { + currentIndex += 1 + break + } + currentIndex -= 1 + } + return IndexedPosition(index: currentIndex) + } else { + return nil + } + } + } +} + +private extension UITextDirection { + var isForward: Bool { + return rawValue == UITextStorageDirection.forward.rawValue + || rawValue == UITextLayoutDirection.right.rawValue + || rawValue == UITextLayoutDirection.down.rawValue + } +} + +private extension CharacterSet { + func contains(_ character: Character) -> Bool { + return character.unicodeScalars.allSatisfy(contains(_:)) } } From 2f85ef6624b7fab40678c1d23b3e488d164345ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 28 Aug 2022 17:38:15 +0200 Subject: [PATCH 25/35] Fixes SwiftLint warnings --- .../TextView/TextInput/LineMovementController.swift | 2 +- .../TextInput/TextInputStringTokenizer.swift | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/LineMovementController.swift b/Sources/Runestone/TextView/TextInput/LineMovementController.swift index 05fbdac5f..490ea5f31 100644 --- a/Sources/Runestone/TextView/TextInput/LineMovementController.swift +++ b/Sources/Runestone/TextView/TextInput/LineMovementController.swift @@ -11,7 +11,7 @@ final class LineMovementController { self.lineControllerStorage = lineControllerStorage } - func location(from location: Int,in direction: UITextLayoutDirection, offset: Int) -> Int? { + func location(from location: Int, in direction: UITextLayoutDirection, offset: Int) -> Int? { let newLocation: Int? switch direction { case .left: diff --git a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift index 15d075457..6553ed858 100644 --- a/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/TextInput/TextInputStringTokenizer.swift @@ -27,11 +27,15 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { } } - override func isPosition(_ position: UITextPosition, withinTextUnit granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool { + override func isPosition(_ position: UITextPosition, + withinTextUnit granularity: UITextGranularity, + inDirection direction: UITextDirection) -> Bool { return super.isPosition(position, withinTextUnit: granularity, inDirection: direction) } - override func position(from position: UITextPosition, toBoundary granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextPosition? { + override func position(from position: UITextPosition, + toBoundary granularity: UITextGranularity, + inDirection direction: UITextDirection) -> UITextPosition? { if granularity == .line { return self.position(from: position, toLineBoundaryInDirection: direction) } else if granularity == .paragraph { @@ -43,7 +47,9 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { } } - override func rangeEnclosingPosition(_ position: UITextPosition, with granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextRange? { + override func rangeEnclosingPosition(_ position: UITextPosition, + with granularity: UITextGranularity, + inDirection direction: UITextDirection) -> UITextRange? { return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) } } @@ -187,6 +193,7 @@ private extension TextInputStringTokenizer { } } + // swiftlint:disable:next cyclomatic_complexity private func position(from position: UITextPosition, toWordBoundaryInDirection direction: UITextDirection) -> UITextPosition? { guard let indexedPosition = position as? IndexedPosition else { return nil From 07cf17703bf5615402d02f94379decdaebf9e203 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 29 Aug 2022 17:35:43 +0200 Subject: [PATCH 26/35] Introduces shouldMoveCaretToNextLineFragment(forLocation:in:) --- .../TextView/TextInput/LayoutManager.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/TextInput/LayoutManager.swift b/Sources/Runestone/TextView/TextInput/LayoutManager.swift index 33c0f8a98..283cb1ae4 100644 --- a/Sources/Runestone/TextView/TextInput/LayoutManager.swift +++ b/Sources/Runestone/TextView/TextInput/LayoutManager.swift @@ -421,8 +421,7 @@ extension LayoutManager { let line = lineManager.line(containingCharacterAt: safeLocation)! let lineController = lineControllerStorage.getOrCreateLineController(for: line) let lineLocalLocation = safeLocation - line.location - let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) - if lineFragmentNode.index > 0, let lineFragment = lineFragmentNode.data.lineFragment, location == lineFragment.range.location { + if shouldMoveCaretToNextLineFragment(forLocation: lineLocalLocation, in: line) { let rect = caretRect(at: location + 1) return CGRect(x: leadingLineSpacing, y: rect.minY, width: rect.width, height: rect.height) } else { @@ -519,6 +518,18 @@ extension LayoutManager { return line.location + index } } + + private func shouldMoveCaretToNextLineFragment(forLocation location: Int, in line: DocumentLineNode) -> Bool { + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + guard lineController.numberOfLineFragments > 0 else { + return false + } + let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: location) + guard lineFragmentNode.index > 0 else { + return false + } + return location == lineFragmentNode.data.lineFragment?.range.location + } } // MARK: - Layout From 674e62a2ea64d01b4f58821d214f4acdbc682a26 Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Thu, 20 Apr 2023 14:14:14 +1000 Subject: [PATCH 27/35] Workaround for runtime crash --- .../Internal/TreeSitter/TreeSitterLanguageLayer.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift index 9ac441769..cfb7e6063 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) From d32d4ce7957970d0b7066daf1d9205fcf1d484cd Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Thu, 20 Apr 2023 14:46:53 +1000 Subject: [PATCH 28/35] Update TextEditor for text coming *from* binding, too --- Sources/RunestoneSwiftUI/TextEditor.swift | 13 +++++++++---- .../RunestoneSwiftUI/TextView+Configuration.swift | 3 +++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/RunestoneSwiftUI/TextEditor.swift b/Sources/RunestoneSwiftUI/TextEditor.swift index 274de5c5d..90cf2813c 100644 --- a/Sources/RunestoneSwiftUI/TextEditor.swift +++ b/Sources/RunestoneSwiftUI/TextEditor.swift @@ -14,14 +14,15 @@ public struct TextEditor: UIViewRepresentable { @Environment(\.themeFontSize) var themeFontSize - public let text: Binding + @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._text = text self.actualTheme = OverridingTheme(base: theme) self.language = language self.configuration = configuration @@ -36,7 +37,7 @@ public struct TextEditor: UIViewRepresentable { textView.apply(configuration) textView.editorDelegate = context.coordinator - context.coordinator.configure(text: text, theme: actualTheme, language: language) { state in + context.coordinator.configure(text: $text, theme: actualTheme, language: language) { state in textView.setState(state) } @@ -64,6 +65,10 @@ public struct TextEditor: UIViewRepresentable { 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) + } } } @@ -79,7 +84,7 @@ public class TextEditorCoordinator: ObservableObject { var text: Binding? func configure(text: Binding, theme: Theme, language: TreeSitterLanguage?, completion: @escaping (TextViewState) -> Void) { - self.text = text + guard self.text?.wrappedValue != text.wrappedValue else { return } DispatchQueue.global(qos: .background).async { let state: TextViewState diff --git a/Sources/RunestoneSwiftUI/TextView+Configuration.swift b/Sources/RunestoneSwiftUI/TextView+Configuration.swift index 747f1fad7..ef6788a31 100644 --- a/Sources/RunestoneSwiftUI/TextView+Configuration.swift +++ b/Sources/RunestoneSwiftUI/TextView+Configuration.swift @@ -18,7 +18,10 @@ extension TextEditor { 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 } } From 00bd8b5572b2d4db9e157532d130759b671e493c Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Sat, 29 Apr 2023 19:23:30 +1000 Subject: [PATCH 29/35] Re-instate line that shouldn't have been deleted, and maintain correct QOS --- Sources/RunestoneSwiftUI/TextEditor.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/RunestoneSwiftUI/TextEditor.swift b/Sources/RunestoneSwiftUI/TextEditor.swift index 90cf2813c..47162de7b 100644 --- a/Sources/RunestoneSwiftUI/TextEditor.swift +++ b/Sources/RunestoneSwiftUI/TextEditor.swift @@ -86,7 +86,9 @@ public class TextEditorCoordinator: ObservableObject { func configure(text: Binding, theme: Theme, language: TreeSitterLanguage?, completion: @escaping (TextViewState) -> Void) { guard self.text?.wrappedValue != text.wrappedValue else { return } - DispatchQueue.global(qos: .background).async { + self.text = text + + DispatchQueue.global(qos: .userInteractive).async { let state: TextViewState if let language = language { state = TextViewState(text: text.wrappedValue, theme: theme, language: language) From a9e2ff034f67bef36187a97d68849cdfbf497b8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Sch=C3=B6nig?= Date: Sat, 2 Dec 2023 12:38:22 +1100 Subject: [PATCH 30/35] Vision OS compile fixes (#3) * visionOS compile fixes * VisionOS compile fix --- Sources/Runestone/TextView/Appearance/Theme.swift | 7 ++++++- Sources/Runestone/TextView/Core/TextView.swift | 6 ++++++ Sources/Runestone/TextView/PageGuide/PageGuideView.swift | 7 ++++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index 6482a2938..4ec3db428 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -67,6 +67,10 @@ public protocol Theme: AnyObject { } public extension Theme { +#if os(visionOS) + var gutterHairlineWidth: CGFloat { 0.5 } + var pageGuideHairlineWidth: CGFloat { 0.5 } +#else var gutterHairlineWidth: CGFloat { 1 / UIScreen.main.scale } @@ -74,7 +78,8 @@ public extension Theme { var pageGuideHairlineWidth: CGFloat { 1 / UIScreen.main.scale } - +#endif + var markedTextBackgroundCornerRadius: CGFloat { 0 } diff --git a/Sources/Runestone/TextView/Core/TextView.swift b/Sources/Runestone/TextView/Core/TextView.swift index fd5096c72..b2259e915 100644 --- a/Sources/Runestone/TextView/Core/TextView.swift +++ b/Sources/Runestone/TextView/Core/TextView.swift @@ -197,6 +197,8 @@ open class TextView: UIScrollView { textInputView.selectedTextRange = newValue } } + +#if !os(visionOS) /// The custom input accessory view to display when the receiver becomes the first responder. override public var inputAccessoryView: UIView? { get { @@ -214,6 +216,8 @@ open class TextView: UIScrollView { override public var inputAssistantItem: UITextInputAssistantItem { textInputView.inputAssistantItem } +#endif + /// Returns a Boolean value indicating whether this object can become the first responder. override public var canBecomeFirstResponder: Bool { !textInputView.isFirstResponder && isEditable @@ -606,7 +610,9 @@ open class TextView: UIScrollView { #endif private let tapGestureRecognizer = QuickTapGestureRecognizer() private var _inputAccessoryView: UIView? +#if !os(visionOS) private let _inputAssistantItem = UITextInputAssistantItem() +#endif private var isPerformingNonEditableTextInteraction = false private var delegateAllowsEditingToBegin: Bool { guard isEditable else { diff --git a/Sources/Runestone/TextView/PageGuide/PageGuideView.swift b/Sources/Runestone/TextView/PageGuide/PageGuideView.swift index 2fd6d14d2..b8968ca8c 100644 --- a/Sources/Runestone/TextView/PageGuide/PageGuideView.swift +++ b/Sources/Runestone/TextView/PageGuide/PageGuideView.swift @@ -1,13 +1,18 @@ import UIKit final class PageGuideView: UIView { - var hairlineWidth: CGFloat = 1 / UIScreen.main.scale { +#if os(visionOS) + var hairlineWidth: CGFloat = 0.5 +#else + var hairlineWidth: CGFloat = 1 / UIScreen.main.scale { didSet { if hairlineWidth != oldValue { setNeedsLayout() } } } +#endif + var hairlineColor: UIColor? { get { hairlineView.backgroundColor From 364a0f457c1b04a968b08af3a42fdff5c24c697c Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Wed, 3 Apr 2024 14:04:22 +1100 Subject: [PATCH 31/35] Merging main into SwiftUI branch --- .github/dependabot.yml | 6 + .github/workflows/build_and_test.yml | 59 +++++-- .github/workflows/build_documentation.yml | 33 ++++ .github/workflows/build_example_project.yml | 46 +++-- .github/workflows/codeql.yml | 50 ++++-- .github/workflows/deploy_documentation.yml | 27 ++- .github/workflows/swiftlint.yml | 17 +- .github/workflows/ui_tests.yml | 40 +++-- .gitmodules | 3 - Example/Example.xcodeproj/project.pbxproj | 53 +++--- .../xcshareddata/swiftpm/Package.resolved | 16 ++ Example/Example/Application/AppDelegate.swift | 6 +- .../Example/Application/SceneDelegate.swift | 12 +- .../Base.lproj/LaunchScreen.storyboard | 25 --- .../Example/Library/BasicCharacterPair.swift | 7 +- .../Example/Library/TextView+Helpers.swift | 6 + Example/Example/Main/KeyboardToolsView.swift | 2 + Example/Example/Main/MainViewController.swift | 166 ++++++++---------- Example/Example/Main/Menu/MenuButton.swift | 91 ++++++++++ Example/Example/Main/Menu/MenuItem.swift | 15 ++ .../Main/Menu/MenuSelectionHandler.swift | 3 + .../Example/Main/Menu/SwiftUIMenuButton.swift | 12 ++ .../ThemePickerViewController.swift | 6 +- Package.resolved | 16 ++ Package.swift | 44 ++--- README.md | 7 +- Scripts/run-ui-test-chinese.sh | 2 +- Scripts/run-ui-test-korean.sh | 2 +- .../Documentation.docc/Documentation.md | 2 + .../Extensions/StringSyntaxHighlighter.md | 45 +++++ .../Documentation.docc/Extensions/TextView.md | 1 - .../SyntaxHighlightingAString.md | 48 +++++ Sources/Runestone/Library/ByteRange.swift | 2 +- .../Library/DefaultStringAttributes.swift | 27 +++ .../Runestone/Library/HairlineLength.swift | 7 + .../Runestone/Library/TabWidthMeasurer.swift | 12 ++ .../Library/UITextInput+Helpers.swift | 23 +++ ...tSelectionDisplayInteraction+Helpers.swift | 12 ++ Sources/Runestone/PrivacyInfo.xcprivacy | 14 ++ .../Runestone/StringSyntaxHighlighter.swift | 105 +++++++++++ .../TextView/Appearance/DefaultTheme.swift | 2 - .../Runestone/TextView/Appearance/Theme.swift | 8 +- .../Contents.json | 18 +- .../Contents.json | 18 +- .../TextView/Core/EditMenuController.swift | 22 +-- .../Core/TextInputStringTokenizer.swift | 19 +- .../TextView/Core/TextInputView.swift | 16 +- .../Runestone/TextView/Core/TextView.swift | 35 ++-- .../TextView/Core/TextViewState.swift | 6 +- .../TextView/Indent/IndentController.swift | 7 +- .../TextView/Indent/IndentStrategy.swift | 2 +- .../LineController/LineController.swift | 21 +-- .../LineFragmentSelectionRect.swift | 6 - .../LineSyntaxHighlighter.swift | 16 -- .../TextView/PageGuide/PageGuideView.swift | 8 +- .../ParsedReplacementString.swift | 6 +- .../SearchAndReplace/StringModifier.swift | 2 +- .../SearchAndReplace/TextPreview.swift | 42 ++--- .../UITextSearchingHelper.swift | 6 - .../Runestone/TreeSitter/TreeSitterNode.swift | 2 +- .../TextInputStringTokenizerTests.swift | 2 +- UITests/HostUITests/KoreanInputTests.swift | 31 ---- .../HostUITests/XCUIApplication+Helpers.swift | 7 - tree-sitter | 1 - 64 files changed, 910 insertions(+), 463 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build_documentation.yml create mode 100644 Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 Example/Example/Base.lproj/LaunchScreen.storyboard create mode 100644 Example/Example/Main/Menu/MenuButton.swift create mode 100644 Example/Example/Main/Menu/MenuItem.swift create mode 100644 Example/Example/Main/Menu/MenuSelectionHandler.swift create mode 100644 Example/Example/Main/Menu/SwiftUIMenuButton.swift create mode 100644 Package.resolved create mode 100644 Sources/Runestone/Documentation.docc/Extensions/StringSyntaxHighlighter.md create mode 100644 Sources/Runestone/Documentation.docc/SyntaxHighlightingAString.md create mode 100644 Sources/Runestone/Library/DefaultStringAttributes.swift create mode 100644 Sources/Runestone/Library/HairlineLength.swift create mode 100644 Sources/Runestone/Library/TabWidthMeasurer.swift create mode 100644 Sources/Runestone/Library/UITextInput+Helpers.swift create mode 100644 Sources/Runestone/Library/UITextSelectionDisplayInteraction+Helpers.swift create mode 100644 Sources/Runestone/PrivacyInfo.xcprivacy create mode 100644 Sources/Runestone/StringSyntaxHighlighter.swift delete mode 160000 tree-sitter diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..acedc30da --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "swift" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml index 86d72a32a..28b6640fd 100644 --- a/.github/workflows/build_and_test.yml +++ b/.github/workflows/build_and_test.yml @@ -1,29 +1,56 @@ name: Build and Test - on: workflow_dispatch: {} pull_request: - branches: [ main ] - paths: - - 'Sources/**' - - '!Sources/Runestone/Documentation.docc/**' - - 'Tests/**' - + branches: + - main +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: build: - name: Build and test on iPhone 14 - runs-on: macOS-12 + name: Build and Test (Xcode ${{ matrix.xcode }}) + runs-on: macos-14 + continue-on-error: true + strategy: + matrix: + include: + - xcode: 15.2 + destination: iPhone 15 Pro + os: 17.2 + - xcode: 14.3.1 + destination: iPhone 14 Pro + os: 16.4 + env: + DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + - name: Check for changed files + uses: dorny/paths-filter@v3 + id: changes with: - submodules: recursive + filters: | + src: + - '.github/workflows/build_and_test.yml' + - 'Sources/**' + - '!Sources/Runestone/Documentation.docc/**' + - 'Tests/**' - name: Build + if: steps.changes.outputs.src == 'true' run: | - xcodebuild build-for-testing -scheme Runestone -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' + set -o pipefail &&\ + xcodebuild build-for-testing\ + -scheme Runestone\ + -sdk iphonesimulator\ + -destination "platform=iOS Simulator,name=${{ matrix.destination }},OS=${{ matrix.os }}"\ + | xcbeautify --renderer github-actions - name: Test - env: - scheme: ${{ 'default' }} - platform: ${{ 'iOS Simulator' }} + if: steps.changes.outputs.src == 'true' run: | - xcodebuild test-without-building -scheme Runestone -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' + set -o pipefail &&\ + xcodebuild test-without-building\ + -scheme Runestone\ + -sdk iphonesimulator\ + -destination "platform=iOS Simulator,name=${{ matrix.destination }},OS=${{ matrix.os }}"\ + | xcbeautify --renderer github-actions diff --git a/.github/workflows/build_documentation.yml b/.github/workflows/build_documentation.yml new file mode 100644 index 000000000..cb983d9aa --- /dev/null +++ b/.github/workflows/build_documentation.yml @@ -0,0 +1,33 @@ +name: Build Documentation +on: + workflow_dispatch: {} + pull_request: + branches: + - main +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + build: + name: Build Documentation + runs-on: macos-14 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Check for changed files + uses: dorny/paths-filter@v3 + id: changes + with: + filters: | + src: + - '.github/workflows/build_documentation.yml' + - 'Sources/**' + - name: Build Documentation + if: steps.changes.outputs.src == 'true' + run: | + set -o pipefail &&\ + xcodebuild docbuild\ + -scheme Runestone\ + -destination 'generic/platform=iOS'\ + -derivedDataPath ../DerivedData\ + | xcbeautify --renderer github-actions diff --git a/.github/workflows/build_example_project.yml b/.github/workflows/build_example_project.yml index bc1d30f73..4157c6c54 100644 --- a/.github/workflows/build_example_project.yml +++ b/.github/workflows/build_example_project.yml @@ -1,21 +1,47 @@ name: Build Example Project - on: workflow_dispatch: {} pull_request: - branches: [ main ] - paths: - - 'Example/**' - + branches: + - main +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: build: - name: Build example project for iPhone 14 - runs-on: macOS-12 + name: Build Example Project (Xcode ${{ matrix.xcode }}) + runs-on: macos-14 + continue-on-error: true + strategy: + matrix: + include: + - xcode: 15.2 + destination: iPhone 15 Pro + os: 17.2 + - xcode: 14.3.1 + destination: iPhone 14 Pro + os: 16.4 + env: + DEVELOPER_DIR: /Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v4 + - name: Check for changed files + uses: dorny/paths-filter@v3 + id: changes with: - submodules: recursive + filters: | + src: + - '.github/workflows/build_example_project.yml' + - 'Example/**' + - 'Sources/**' - name: Build + if: steps.changes.outputs.src == 'true' run: | - xcodebuild build -project Example/Example.xcodeproj -scheme Example -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' + set -o pipefail &&\ + xcodebuild build\ + -project Example/Example.xcodeproj\ + -scheme Example\ + -sdk iphonesimulator\ + -destination "platform=iOS Simulator,name=${{ matrix.destination }},OS=${{ matrix.os }}"\ + | xcbeautify --renderer github-actions diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a67650266..4934398ea 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,38 +1,56 @@ name: "CodeQL" on: push: - branches: [ "main" ] + branches: + - main pull_request: - branches: [ "main" ] - + branches: + - main +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true env: - CODEQL_ENABLE_EXPERIMENTAL_FEATURES_SWIFT: true - + XCODEBUILD_DESTINATION: iPhone 15 Pro + XCODEBUILD_OS: 17.2 jobs: analyze: name: Analyze - runs-on: macos-latest - + runs-on: macos-14 permissions: security-events: write - strategy: fail-fast: false matrix: - language: [ "swift" ] - + language: [ "swift", "c-cpp" ] steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: Checkout + uses: actions/checkout@v4 + - name: Check for changed files + uses: dorny/paths-filter@v3 + id: changes with: - submodules: recursive + filters: | + src: + - 'Example/**' + - 'Sources/**' + - 'Tests/**' + - 'UITests/**' - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + if: steps.changes.outputs.src == 'true' + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - name: Build - run: xcodebuild -scheme Runestone -sdk iphonesimulator -destination 'platform=iOS Simulator,name=iPhone 14' + if: steps.changes.outputs.src == 'true' + run: | + set -o pipefail &&\ + xcodebuild\ + -scheme Runestone\ + -sdk iphonesimulator\ + -destination "platform=iOS Simulator,name=${{ env.XCODEBUILD_DESTINATION }},OS=${{ env.XCODEBUILD_OS }}"\ + | xcbeautify --renderer github-actions - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 + if: steps.changes.outputs.src == 'true' with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/deploy_documentation.yml b/.github/workflows/deploy_documentation.yml index e6b94f615..a4d5dd1e5 100644 --- a/.github/workflows/deploy_documentation.yml +++ b/.github/workflows/deploy_documentation.yml @@ -1,35 +1,30 @@ name: Deploy Documentation - on: - workflow_dispatch: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages + workflow_dispatch: {} permissions: contents: read pages: write id-token: write - -# Allow one concurrent deployment concurrency: group: "pages" cancel-in-progress: true - jobs: build: - runs-on: macos-12 + name: Build Documentation + runs-on: macos-14 steps: - name: Checkout - uses: actions/checkout@v3 - with: - submodules: recursive + uses: actions/checkout@v4 - name: Setup Pages - uses: actions/configure-pages@v2 + uses: actions/configure-pages@v4 - name: Build Documentation run: | + set -o pipefail &&\ xcodebuild docbuild\ -scheme Runestone\ -destination 'generic/platform=iOS'\ - -derivedDataPath ../DerivedData + -derivedDataPath ../DerivedData\ + | xcbeautify --renderer github-actions - name: Process Archive run: | mkdir _site @@ -52,9 +47,9 @@ jobs: EOM - name: Upload Artifact - uses: actions/upload-pages-artifact@v1 - + uses: actions/upload-pages-artifact@v3 deploy: + name: Deploy Documentation environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -63,4 +58,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v1 + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml index 3f72866ed..9107f711f 100644 --- a/.github/workflows/swiftlint.yml +++ b/.github/workflows/swiftlint.yml @@ -1,22 +1,17 @@ name: SwiftLint - on: workflow_dispatch: {} - pull_request: - paths: - - '.github/workflows/swiftlint.yml' - - '.swiftlint.yml' - - '**/*.swift' - + pull_request: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: SwiftLint: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 - with: - submodules: recursive - - name: GitHub Action for SwiftLint + uses: actions/checkout@v4 + - name: Run SwiftLint uses: norio-nomura/action-swiftlint@3.2.1 with: args: --strict diff --git a/.github/workflows/ui_tests.yml b/.github/workflows/ui_tests.yml index ae430a198..8d4f5c5c4 100644 --- a/.github/workflows/ui_tests.yml +++ b/.github/workflows/ui_tests.yml @@ -1,35 +1,49 @@ +# --- +# This workflow is disabled because multi-stage input, which is used when writing text +# in languages like Korean and Chinese, is broken in the iOS 16 and iOS 17 simulators. +# However, it still works on the device. Multi-stage text input does work in the +# iOS 15 simulator, which is used in this workflow. +# +# The iOS 15 simulators are not installed on GitHub's macOS 13 and macOS 14 runners, +# but they are installed in GitHub's macOS 12 runners. However, the iOS 15 simulators +# are only available in Xcode 13.4.1 on those runners, and Xcode 14.3.1 cannot be used +# to build this project as the tree-sitter dependency uses Swift tools version 5.8, +# which is not supported by Xcode 13.4.1. In fact, the most recent version installed on +# the macOS 12 runners is Xcode 14.3, which does not support Swift tools version 5.8 either. +# --- name: UI Tests - -concurrency: - group: ui-tests-${{ github.ref }} - cancel-in-progress: true - on: workflow_dispatch: {} pull_request: - branches: [ main ] + branches: + - main paths: + - '.github/workflows/ui_tests.yml' - 'Sources/**' - '!Sources/Runestone/Documentation.docc/**' - 'UITests/HostUITests/**' - +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +env: + DEVELOPER_DIR: /Applications/Xcode_15.0.1.app/Contents/Developer jobs: run_korean_tests: name: Run Korean tests - runs-on: macOS-12 + runs-on: macos-13 env: SIMULATOR_NAME: UI Test (Korean) SCHEME: Host PROJECT_PATH: UITests/UITests.xcodeproj steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: submodules: recursive - name: Disable "Use the Same Keyboard Language as macOS" run: defaults write com.apple.iphonesimulator EnableKeyboardSync -bool NO - name: Create Simulator - run: xcrun simctl create "${SIMULATOR_NAME}" "iPhone 8" + run: xcrun simctl create "${SIMULATOR_NAME}" "iPhone 13" "iOS15.5" - name: Find Simulator UDID run: | TMP_SIMULATOR_UDID=`xcrun simctl list --json devices | jq -r ".devices | flatten | .[] | select(.name == \"${SIMULATOR_NAME}\").udid"` @@ -80,20 +94,20 @@ jobs: run: xcrun simctl delete $SIMULATOR_UDID run_chinese_tests: name: Run Chinese tests - runs-on: macOS-12 + runs-on: macos-13 env: SIMULATOR_NAME: UI Test (Chinese) SCHEME: Host PROJECT_PATH: UITests/UITests.xcodeproj steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: submodules: recursive - name: Disable "Use the Same Keyboard Language as macOS" run: defaults write com.apple.iphonesimulator EnableKeyboardSync -bool NO - name: Create Simulator - run: xcrun simctl create "${SIMULATOR_NAME}" "iPhone 8" + run: xcrun simctl create "${SIMULATOR_NAME}" "iPhone 13" "iOS15.5" - name: Find Simulator UDID run: | TMP_SIMULATOR_UDID=`xcrun simctl list --json devices | jq -r ".devices | flatten | .[] | select(.name == \"${SIMULATOR_NAME}\").udid"` diff --git a/.gitmodules b/.gitmodules index 7b334c532..e69de29bb 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "tree-sitter"] - path = tree-sitter - url = https://github.com/tree-sitter/tree-sitter.git diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index e1b7454c1..f7fe981b5 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -12,6 +12,10 @@ 7216EACC2829A3C6001B6D39 /* RunestonePlainTextTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 7216EACB2829A3C6001B6D39 /* RunestonePlainTextTheme */; }; 7216EACE2829A3C6001B6D39 /* RunestoneTomorrowNightTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 7216EACD2829A3C6001B6D39 /* RunestoneTomorrowNightTheme */; }; 7216EAD02829A3C6001B6D39 /* RunestoneTomorrowTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 7216EACF2829A3C6001B6D39 /* RunestoneTomorrowTheme */; }; + 72417DCA2B4E7315009EB32B /* SwiftUIMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72417DC92B4E7315009EB32B /* SwiftUIMenuButton.swift */; }; + 72417DD02B4E7492009EB32B /* MenuSelectionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72417DCF2B4E7492009EB32B /* MenuSelectionHandler.swift */; }; + 72417DD22B4E74A0009EB32B /* MenuItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72417DD12B4E74A0009EB32B /* MenuItem.swift */; }; + 72417DD42B4E7645009EB32B /* MenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72417DD32B4E7645009EB32B /* MenuButton.swift */; }; 72AC54812826B2A90037ED21 /* Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 72AC54802826B2A90037ED21 /* Runestone */; }; 72D2718229126F190070FA88 /* ProcessInfo+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D2718129126F190070FA88 /* ProcessInfo+Helpers.swift */; }; AC480601279EE0180015F712 /* BasicCharacterPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC480600279EE0180015F712 /* BasicCharacterPair.swift */; }; @@ -26,12 +30,15 @@ ACFDF4B327983BAA00059A1B /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACFDF4B227983BAA00059A1B /* SceneDelegate.swift */; }; ACFDF4B527983BAA00059A1B /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACFDF4B427983BAA00059A1B /* MainViewController.swift */; }; ACFDF4BA27983BAB00059A1B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = ACFDF4B927983BAB00059A1B /* Assets.xcassets */; }; - ACFDF4BD27983BAB00059A1B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = ACFDF4BB27983BAB00059A1B /* LaunchScreen.storyboard */; }; ACFDF4C527983C2700059A1B /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACFDF4C427983C2700059A1B /* MainView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 7216EAC62829A16C001B6D39 /* Themes */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Themes; sourceTree = ""; }; + 72417DC92B4E7315009EB32B /* SwiftUIMenuButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIMenuButton.swift; sourceTree = ""; }; + 72417DCF2B4E7492009EB32B /* MenuSelectionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSelectionHandler.swift; sourceTree = ""; }; + 72417DD12B4E74A0009EB32B /* MenuItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuItem.swift; sourceTree = ""; }; + 72417DD32B4E7645009EB32B /* MenuButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuButton.swift; sourceTree = ""; }; 7243F9BA282D73E9005AAABF /* Example.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Example.entitlements; sourceTree = ""; }; 72AC54762826B1F00037ED21 /* Languages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Languages; sourceTree = ""; }; 72AC54772826B23D0037ED21 /* Runestone */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Runestone; path = ..; sourceTree = ""; }; @@ -49,7 +56,6 @@ ACFDF4B227983BAA00059A1B /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; ACFDF4B427983BAA00059A1B /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; ACFDF4B927983BAB00059A1B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - ACFDF4BC27983BAB00059A1B /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; ACFDF4BE27983BAB00059A1B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; ACFDF4C427983C2700059A1B /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -71,6 +77,17 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 72417DD52B4E7A85009EB32B /* Menu */ = { + isa = PBXGroup; + children = ( + 72417DC92B4E7315009EB32B /* SwiftUIMenuButton.swift */, + 72417DCF2B4E7492009EB32B /* MenuSelectionHandler.swift */, + 72417DD12B4E74A0009EB32B /* MenuItem.swift */, + 72417DD32B4E7645009EB32B /* MenuButton.swift */, + ); + path = Menu; + sourceTree = ""; + }; 72AC54722826B0E40037ED21 /* Packages */ = { isa = PBXGroup; children = ( @@ -93,9 +110,10 @@ AC832D592798C73300EC6832 /* Main */ = { isa = PBXGroup; children = ( + 72417DD52B4E7A85009EB32B /* Menu */, + AC85537E27A849DF00F7916D /* KeyboardToolsView.swift */, ACFDF4C427983C2700059A1B /* MainView.swift */, ACFDF4B427983BAA00059A1B /* MainViewController.swift */, - AC85537E27A849DF00F7916D /* KeyboardToolsView.swift */, ); path = Main; sourceTree = ""; @@ -146,7 +164,6 @@ 7243F9BA282D73E9005AAABF /* Example.entitlements */, ACFDF4BE27983BAB00059A1B /* Info.plist */, ACFDF4B927983BAB00059A1B /* Assets.xcassets */, - ACFDF4BB27983BAB00059A1B /* LaunchScreen.storyboard */, AC832D582798C72A00EC6832 /* Application */, AC832D5A2798C73B00EC6832 /* Library */, AC832D592798C73300EC6832 /* Main */, @@ -231,7 +248,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - ACFDF4BD27983BAB00059A1B /* LaunchScreen.storyboard in Resources */, ACFDF4BA27983BAB00059A1B /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -273,27 +289,20 @@ ACFDF4B327983BAA00059A1B /* SceneDelegate.swift in Sources */, AC85537D27A845BB00F7916D /* CodeSample.swift in Sources */, AC85537927A83D2000F7916D /* ThemeSetting.swift in Sources */, + 72417DD02B4E7492009EB32B /* MenuSelectionHandler.swift in Sources */, + 72417DD22B4E74A0009EB32B /* MenuItem.swift in Sources */, AC85538527A84CF600F7916D /* TextView+Helpers.swift in Sources */, + 72417DCA2B4E7315009EB32B /* SwiftUIMenuButton.swift in Sources */, ACB08AD527A81ADF00EB6819 /* ThemePickerPreviewCell.swift in Sources */, 72D2718229126F190070FA88 /* ProcessInfo+Helpers.swift in Sources */, AC5F0253279C74A1001D1E43 /* UserDefaults+Helpers.swift in Sources */, AC480601279EE0180015F712 /* BasicCharacterPair.swift in Sources */, + 72417DD42B4E7645009EB32B /* MenuButton.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXVariantGroup section */ - ACFDF4BB27983BAB00059A1B /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - ACFDF4BC27983BAB00059A1B /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ ACFDF4BF27983BAB00059A1B /* Debug */ = { isa = XCBuildConfiguration; @@ -423,7 +432,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Example/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( @@ -433,11 +442,13 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dk.simonbs.Example; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,6"; + TARGETED_DEVICE_FAMILY = "1,2,6,7"; }; name = Debug; }; @@ -453,7 +464,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Example/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; - INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( @@ -463,11 +474,13 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dk.simonbs.Example; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator xros xrsimulator"; SUPPORTS_MACCATALYST = YES; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,6"; + TARGETED_DEVICE_FAMILY = "1,2,6,7"; }; name = Release; }; diff --git a/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..ffc010f2f --- /dev/null +++ b/Example/Example.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "TreeSitter", + "repositoryURL": "https://github.com/tree-sitter/tree-sitter", + "state": { + "branch": null, + "revision": "98be227227af10cc7a269cb3ffb23686c0610b17", + "version": "0.20.9" + } + } + ] + }, + "version": 1 +} diff --git a/Example/Example/Application/AppDelegate.swift b/Example/Example/Application/AppDelegate.swift index 12f58c86b..8204efbe0 100644 --- a/Example/Example/Application/AppDelegate.swift +++ b/Example/Example/Application/AppDelegate.swift @@ -2,8 +2,10 @@ import UIKit @main final class AppDelegate: UIResponder, UIApplicationDelegate { - func application(_ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { UserDefaults.standard.registerDefaults() return true } diff --git a/Example/Example/Application/SceneDelegate.swift b/Example/Example/Application/SceneDelegate.swift index c0c58a740..5d02eda52 100644 --- a/Example/Example/Application/SceneDelegate.swift +++ b/Example/Example/Application/SceneDelegate.swift @@ -3,9 +3,11 @@ import UIKit final class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? - func scene(_ scene: UIScene, - willConnectTo session: UISceneSession, - options connectionOptions: UIScene.ConnectionOptions) { + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { guard let windowScene = scene as? UIWindowScene else { fatalError("Unexpected type of scene: \(type(of: scene))") } @@ -18,6 +20,7 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate { private extension SceneDelegate { private func makeRootViewController() -> UIViewController { + #if os(iOS) let mainViewController = MainViewController() let navigationController = UINavigationController(rootViewController: mainViewController) let navigationBarAppearance = UINavigationBarAppearance() @@ -26,5 +29,8 @@ private extension SceneDelegate { navigationController.navigationBar.compactAppearance = navigationBarAppearance navigationController.navigationBar.scrollEdgeAppearance = navigationBarAppearance return navigationController + #else + MainViewController() + #endif } } diff --git a/Example/Example/Base.lproj/LaunchScreen.storyboard b/Example/Example/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 865e9329f..000000000 --- a/Example/Example/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Example/Example/Library/BasicCharacterPair.swift b/Example/Example/Library/BasicCharacterPair.swift index a366cfa95..8b22aa15b 100644 --- a/Example/Example/Library/BasicCharacterPair.swift +++ b/Example/Example/Library/BasicCharacterPair.swift @@ -1,11 +1,6 @@ import Runestone -final class BasicCharacterPair: CharacterPair { +struct BasicCharacterPair: CharacterPair { let leading: String let trailing: String - - init(leading: String, trailing: String) { - self.leading = leading - self.trailing = trailing - } } diff --git a/Example/Example/Library/TextView+Helpers.swift b/Example/Example/Library/TextView+Helpers.swift index cf292ece7..7c1e743dc 100644 --- a/Example/Example/Library/TextView+Helpers.swift +++ b/Example/Example/Library/TextView+Helpers.swift @@ -12,12 +12,18 @@ extension TextView { textView.smartDashesType = .no textView.smartQuotesType = .no textView.smartInsertDeleteType = .no + #if os(iOS) textView.textContainerInset = UIEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) + #else + textView.textContainerInset = UIEdgeInsets(top: 15, left: 5, bottom: 15, right: 5) + #endif textView.lineSelectionDisplayType = .line textView.lineHeightMultiplier = 1.3 textView.kern = 0.3 textView.pageGuideColumn = 80 + #if os(iOS) textView.inputAccessoryView = KeyboardToolsView(textView: textView) + #endif textView.characterPairs = [ BasicCharacterPair(leading: "(", trailing: ")"), BasicCharacterPair(leading: "{", trailing: "}"), diff --git a/Example/Example/Main/KeyboardToolsView.swift b/Example/Example/Main/KeyboardToolsView.swift index f206a1450..1b45657ff 100644 --- a/Example/Example/Main/KeyboardToolsView.swift +++ b/Example/Example/Main/KeyboardToolsView.swift @@ -1,3 +1,4 @@ +#if os(iOS) import Runestone import UIKit @@ -125,3 +126,4 @@ private extension KeyboardToolsView { redoButton.isEnabled = undoManager?.canRedo ?? false } } +#endif diff --git a/Example/Example/Main/MainViewController.swift b/Example/Example/Main/MainViewController.swift index 2bf42e950..2d2df1515 100644 --- a/Example/Example/Main/MainViewController.swift +++ b/Example/Example/Main/MainViewController.swift @@ -1,28 +1,37 @@ import Runestone import RunestoneJavaScriptLanguage +import SwiftUI import UIKit final class MainViewController: UIViewController { override var textInputContextIdentifier: String? { // Returning a unique identifier makes iOS remember the user's selection of keyboard. - return "RunestoneExample.Main" + "RunestoneExample.Main" } private let contentView = MainView() + #if os(iOS) private let toolsView: KeyboardToolsView + #endif init() { + #if os(iOS) toolsView = KeyboardToolsView(textView: contentView.textView) + #endif super.init(nibName: nil, bundle: nil) title = "Example" - NotificationCenter.default.addObserver(self, - selector: #selector(keyboardWillChangeFrame(_:)), - name: UIApplication.keyboardWillChangeFrameNotification, - object: nil) - NotificationCenter.default.addObserver(self, - selector: #selector(keyboardWillHide(_:)), - name: UIApplication.keyboardWillHideNotification, - object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillChangeFrame(_:)), + name: UIApplication.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillHide(_:)), + name: UIApplication.keyboardWillHideNotification, + object: nil + ) } required init?(coder: NSCoder) { @@ -35,12 +44,23 @@ final class MainViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() -#if compiler(>=5.7) if #available(iOS 16, *) { contentView.textView.isFindInteractionEnabled = true } -#endif + #if os(iOS) contentView.textView.inputAccessoryView = toolsView + #endif + #if compiler(>=5.9) && os(visionOS) + ornaments = [ + UIHostingOrnament(sceneAnchor: .topTrailing, contentAlignment: .bottomTrailing) { + HStack { + SwiftUIMenuButton(selectionHandler: self) + .glassBackgroundEffect() + } + .padding(.trailing) + } + ] + #endif setupMenuButton() setupTextView() updateTextViewSettings() @@ -48,7 +68,6 @@ final class MainViewController: UIViewController { } private extension MainViewController { -#if compiler(>=5.7) @available(iOS 16, *) @objc private func presentFind() { contentView.textView.findInteraction?.presentFindNavigator(showingReplace: false) @@ -58,7 +77,6 @@ private extension MainViewController { @objc private func presentFindAndReplace() { contentView.textView.findInteraction?.presentFindNavigator(showingReplace: true) } -#endif private func setupTextView() { var text = "" @@ -83,85 +101,8 @@ private extension MainViewController { } private func setupMenuButton() { - let menu = UIMenu(children: makeFeaturesMenuElements() + makeSettingsMenuElements() + makeThemeMenuElements()) - navigationItem.rightBarButtonItem = UIBarButtonItem(image: UIImage(systemName: "ellipsis"), menu: menu) - } - - private func makeFeaturesMenuElements() -> [UIMenuElement] { - var menuElements: [UIMenuElement] = [] -#if compiler(>=5.7) - if #available(iOS 16, *) { - menuElements += [ - UIMenu(options: .displayInline, children: [ - UIAction(title: "Find") { [weak self] _ in - self?.presentFind() - }, - UIAction(title: "Find and Replace") { [weak self] _ in - self?.presentFindAndReplace() - } - ]) - ] - } -#endif - menuElements += [ - UIAction(title: "Go to Line") { [weak self] _ in - self?.presentGoToLineAlert() - } - ] - return menuElements - } - - private func makeSettingsMenuElements() -> [UIMenuElement] { - let settings = UserDefaults.standard - return [ - UIMenu(options: .displayInline, children: [ - UIAction(title: "Show Line Numbers", state: settings.showLineNumbers ? .on : .off) { [weak self] _ in - settings.showLineNumbers.toggle() - self?.updateTextViewSettings() - self?.setupMenuButton() - }, - UIAction(title: "Show Page Guide", state: settings.showPageGuide ? .on : .off) { [weak self] _ in - settings.showPageGuide.toggle() - self?.updateTextViewSettings() - self?.setupMenuButton() - }, - UIAction(title: "Show Invisible Characters", state: settings.showInvisibleCharacters ? .on : .off) { [weak self] _ in - settings.showInvisibleCharacters.toggle() - self?.updateTextViewSettings() - self?.setupMenuButton() - }, - UIAction(title: "Wrap Lines", state: settings.wrapLines ? .on : .off) { [weak self] _ in - settings.wrapLines.toggle() - self?.updateTextViewSettings() - self?.setupMenuButton() - }, - UIAction(title: "Highlight Selected Line", state: settings.highlightSelectedLine ? .on : .off) { [weak self] _ in - settings.highlightSelectedLine.toggle() - self?.updateTextViewSettings() - self?.setupMenuButton() - } - ]), - UIMenu(options: .displayInline, children: [ - UIAction(title: "Allow Editing", state: settings.isEditable ? .on : .off) { [weak self] _ in - settings.isEditable.toggle() - self?.updateTextViewSettings() - self?.setupMenuButton() - }, - UIAction(title: "Allow Selection", state: settings.isSelectable ? .on : .off) { [weak self] _ in - settings.isSelectable.toggle() - self?.updateTextViewSettings() - self?.setupMenuButton() - } - ]) - ] - } - - private func makeThemeMenuElements() -> [UIMenuElement] { - [ - UIAction(title: "Theme") { [weak self] _ in - self?.presentThemePicker() - } - ] + let menuButton = MenuButton.makeConfigured(with: self) + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: menuButton) } private func presentGoToLineAlert() { @@ -219,6 +160,47 @@ extension MainViewController: TextViewDelegate { } } +extension MainViewController: MenuSelectionHandler { + // swiftlint:disable:next cyclomatic_complexity + func handleSelection(of menuItem: MenuItem) { + switch menuItem { + case .presentFind: + if #available(iOS 16, *) { + presentFind() + } + case .presentFindAndReplace: + if #available(iOS 16, *) { + presentFindAndReplace() + } + case .presentGoToLine: + presentGoToLineAlert() + case .presentThemePicker: + presentThemePicker() + case .toggleEditable: + UserDefaults.standard.isEditable.toggle() + updateTextViewSettings() + case .toggleInvisibleCharacters: + UserDefaults.standard.showInvisibleCharacters.toggle() + updateTextViewSettings() + case .toggleHighlightSelectedLine: + UserDefaults.standard.highlightSelectedLine.toggle() + updateTextViewSettings() + case .toggleLineNumbers: + UserDefaults.standard.showLineNumbers.toggle() + updateTextViewSettings() + case .togglePageGuide: + UserDefaults.standard.showPageGuide.toggle() + updateTextViewSettings() + case .toggleSelectable: + UserDefaults.standard.isSelectable.toggle() + updateTextViewSettings() + case .toggleWrapLines: + UserDefaults.standard.wrapLines.toggle() + updateTextViewSettings() + } + } +} + extension MainViewController: ThemePickerViewControllerDelegate { func themePickerViewController(_ viewController: ThemePickerViewController, didPick theme: ThemeSetting) { UserDefaults.standard.theme = theme diff --git a/Example/Example/Main/Menu/MenuButton.swift b/Example/Example/Main/Menu/MenuButton.swift new file mode 100644 index 000000000..66b5e1a4c --- /dev/null +++ b/Example/Example/Main/Menu/MenuButton.swift @@ -0,0 +1,91 @@ +import UIKit + +final class MenuButton: UIButton { + private weak var selectionHandler: MenuSelectionHandler? + + static func makeConfigured(with selectionHandler: MenuSelectionHandler) -> UIButton { + var configuration: UIButton.Configuration = .plain() + configuration.image = UIImage(systemName: "ellipsis") + let button = MenuButton(configuration: configuration) + button.selectionHandler = selectionHandler + button.showsMenuAsPrimaryAction = true + button.setupMenu() + return button + } +} + +private extension MenuButton { + private func setupMenu() { + menu = UIMenu(children: makeFeaturesMenuElements() + makeSettingsMenuElements() + makeThemeMenuElements()) + } +} + +private extension MenuButton { + private func makeFeaturesMenuElements() -> [UIMenuElement] { + var menuElements: [UIMenuElement] = [] + if #available(iOS 16, *) { + menuElements += [ + UIMenu(options: .displayInline, children: [ + UIAction(title: "Find") { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .presentFind) + }, + UIAction(title: "Find and Replace") { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .presentFindAndReplace) + } + ]) + ] + } + menuElements += [ + UIAction(title: "Go to Line") { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .presentGoToLine) + } + ] + return menuElements + } + + private func makeSettingsMenuElements() -> [UIMenuElement] { + let settings = UserDefaults.standard + return [ + UIMenu(options: .displayInline, children: [ + UIAction(title: "Show Line Numbers", state: settings.showLineNumbers ? .on : .off) { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .toggleLineNumbers) + self?.setupMenu() + }, + UIAction(title: "Show Page Guide", state: settings.showPageGuide ? .on : .off) { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .togglePageGuide) + self?.setupMenu() + }, + UIAction(title: "Show Invisible Characters", state: settings.showInvisibleCharacters ? .on : .off) { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .toggleInvisibleCharacters) + self?.setupMenu() + }, + UIAction(title: "Wrap Lines", state: settings.wrapLines ? .on : .off) { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .toggleWrapLines) + self?.setupMenu() + }, + UIAction(title: "Highlight Selected Line", state: settings.highlightSelectedLine ? .on : .off) { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .toggleHighlightSelectedLine) + self?.setupMenu() + } + ]), + UIMenu(options: .displayInline, children: [ + UIAction(title: "Allow Editing", state: settings.isEditable ? .on : .off) { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .toggleEditable) + self?.setupMenu() + }, + UIAction(title: "Allow Selection", state: settings.isSelectable ? .on : .off) { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .toggleSelectable) + self?.setupMenu() + } + ]) + ] + } + + private func makeThemeMenuElements() -> [UIMenuElement] { + [ + UIAction(title: "Theme") { [weak self] _ in + self?.selectionHandler?.handleSelection(of: .presentThemePicker) + } + ] + } +} diff --git a/Example/Example/Main/Menu/MenuItem.swift b/Example/Example/Main/Menu/MenuItem.swift new file mode 100644 index 000000000..a9346e2ec --- /dev/null +++ b/Example/Example/Main/Menu/MenuItem.swift @@ -0,0 +1,15 @@ +import Foundation + +enum MenuItem { + case presentFind + case presentFindAndReplace + case presentGoToLine + case presentThemePicker + case toggleEditable + case toggleInvisibleCharacters + case toggleHighlightSelectedLine + case toggleLineNumbers + case togglePageGuide + case toggleSelectable + case toggleWrapLines +} diff --git a/Example/Example/Main/Menu/MenuSelectionHandler.swift b/Example/Example/Main/Menu/MenuSelectionHandler.swift new file mode 100644 index 000000000..51c28864e --- /dev/null +++ b/Example/Example/Main/Menu/MenuSelectionHandler.swift @@ -0,0 +1,3 @@ +protocol MenuSelectionHandler: AnyObject { + func handleSelection(of menuItem: MenuItem) +} diff --git a/Example/Example/Main/Menu/SwiftUIMenuButton.swift b/Example/Example/Main/Menu/SwiftUIMenuButton.swift new file mode 100644 index 000000000..5f0563e26 --- /dev/null +++ b/Example/Example/Main/Menu/SwiftUIMenuButton.swift @@ -0,0 +1,12 @@ +import SwiftUI +import UIKit + +struct SwiftUIMenuButton: UIViewRepresentable { + let selectionHandler: MenuSelectionHandler + + func makeUIView(context: Context) -> some UIView { + MenuButton.makeConfigured(with: selectionHandler) + } + + func updateUIView(_ uiView: UIViewType, context: Context) {} +} diff --git a/Example/Example/ThemePicker/ThemePickerViewController.swift b/Example/Example/ThemePicker/ThemePickerViewController.swift index a7f333414..d507674be 100644 --- a/Example/Example/ThemePicker/ThemePickerViewController.swift +++ b/Example/Example/ThemePicker/ThemePickerViewController.swift @@ -39,7 +39,11 @@ final class ThemePickerViewController: UITableViewController { self.selectedTheme = selectedTheme super.init(style: .insetGrouped) title = "Theme" - navigationItem.leftBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done)) + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(done) + ) } required init?(coder: NSCoder) { diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 000000000..ffc010f2f --- /dev/null +++ b/Package.resolved @@ -0,0 +1,16 @@ +{ + "object": { + "pins": [ + { + "package": "TreeSitter", + "repositoryURL": "https://github.com/tree-sitter/tree-sitter", + "state": { + "branch": null, + "revision": "98be227227af10cc7a269cb3ffb23686c0610b17", + "version": "0.20.9" + } + } + ] + }, + "version": 1 +} diff --git a/Package.swift b/Package.swift index e3f85704b..dfef56209 100644 --- a/Package.swift +++ b/Package.swift @@ -13,35 +13,23 @@ let package = Package( .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")) + ], targets: [ - .target(name: "Runestone", - dependencies: ["TreeSitter"], - resources: [.process("TextView/Appearance/Theme.xcassets")]), + .target(name: "Runestone", dependencies: [ + .product(name: "TreeSitter", package: "tree-sitter") + ], resources: [ + .copy("PrivacyInfo.xcprivacy"), + .process("TextView/Appearance/Theme.xcassets") + ]), .target(name: "RunestoneSwiftUI", dependencies: ["Runestone"]), - .target(name: "TreeSitter", - path: "tree-sitter/lib", - exclude: [ - "binding_rust", - "binding_web", - "Cargo.toml", - "README.md", - "src/unicode/README.md", - "src/unicode/LICENSE", - "src/unicode/ICU_SHA", - "src/get_changed_ranges.c", - "src/tree_cursor.c", - "src/stack.c", - "src/node.c", - "src/lexer.c", - "src/parser.c", - "src/language.c", - "src/alloc.c", - "src/subtree.c", - "src/tree.c", - "src/query.c" - ], - sources: ["src/lib.c"]), - .target(name: "TestTreeSitterLanguages", cSettings: [.unsafeFlags(["-w"])]), - .testTarget(name: "RunestoneTests", dependencies: ["Runestone", "TestTreeSitterLanguages"]) + .target(name: "TestTreeSitterLanguages", cSettings: [ + .unsafeFlags(["-w"]) + ]), + .testTarget(name: "RunestoneTests", dependencies: [ + "Runestone", + "TestTreeSitterLanguages" + ]) ] ) diff --git a/README.md b/README.md index a893aa2be..1c1fee647 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,12 @@ Runestone uses GitHub's [Tree-sitter](https://tree-sitter.github.io/tree-sitter/ [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fsimonbs%2FRunestone%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/simonbs/Runestone) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fsimonbs%2FRunestone%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/simonbs/Runestone) [![Build and Test](https://github.com/simonbs/Runestone/actions/workflows/build_and_test.yml/badge.svg)](https://github.com/simonbs/Runestone/actions/workflows/build_and_test.yml) -[![UI Tests](https://github.com/simonbs/Runestone/actions/workflows/ui_tests.yml/badge.svg)](https://github.com/simonbs/Runestone/actions/workflows/ui_tests.yml) +[![Build Documentation](https://github.com/simonbs/Runestone/actions/workflows/build_documentation.yml/badge.svg)](https://github.com/simonbs/Runestone/actions/workflows/build_documentation.yml) +[![Build Example Project](https://github.com/simonbs/Runestone/actions/workflows/build_example_project.yml/badge.svg)](https://github.com/simonbs/Runestone/actions/workflows/build_example_project.yml) +[![CodeQL](https://github.com/simonbs/Runestone/actions/workflows/codeql.yml/badge.svg)](https://github.com/simonbs/Runestone/actions/workflows/codeql.yml) [![SwiftLint](https://github.com/simonbs/Runestone/actions/workflows/swiftlint.yml/badge.svg)](https://github.com/simonbs/Runestone/actions/workflows/swiftlint.yml) -[![Twitter](https://img.shields.io/badge/twitter-@simonbs-blue.svg?style=flat)](https://twitter.com/simonbs) +[![Twitter](https://img.shields.io/badge/Twitter-@simonbs-blue.svg?style=flat)](https://twitter.com/simonbs) +[![Mastodon](https://img.shields.io/badge/Mastodon-@simonbs-blue.svg?style=flat)](https://mastodon.social/@simonbs) ## ✨ Features diff --git a/Scripts/run-ui-test-chinese.sh b/Scripts/run-ui-test-chinese.sh index 59c84e360..ba925b3bb 100755 --- a/Scripts/run-ui-test-chinese.sh +++ b/Scripts/run-ui-test-chinese.sh @@ -6,7 +6,7 @@ PROJECT_PATH="${SCRIPT_PATH}/../UITests/UITests.xcodeproj" # Disable "Use the Same Keyboard Language as macOS" in Simulator.app. defaults write com.apple.iphonesimulator EnableKeyboardSync -bool NO # Create the simulator we will use for the tests. -xcrun simctl create "${SIMULATOR_NAME}" "iPhone 8" 2> /dev/null +xcrun simctl create "${SIMULATOR_NAME}" "iPhone 13" "iOS15.5" 2> /dev/null # Find the UDID of the newly created simulator. SIMULATOR_UDID=`xcrun simctl list --json devices | jq -r ".devices | flatten | .[] | select(.name == \"${SIMULATOR_NAME}\").udid"` # Edit the simulator's .GlobalPreferences.plist to use the Chinese language. diff --git a/Scripts/run-ui-test-korean.sh b/Scripts/run-ui-test-korean.sh index 7170329f4..dca4197fd 100755 --- a/Scripts/run-ui-test-korean.sh +++ b/Scripts/run-ui-test-korean.sh @@ -6,7 +6,7 @@ PROJECT_PATH="${SCRIPT_PATH}/../UITests/UITests.xcodeproj" # Disable "Use the Same Keyboard Language as macOS" in Simulator.app. defaults write com.apple.iphonesimulator EnableKeyboardSync -bool NO # Create the simulator we will use for the tests. -xcrun simctl create "${SIMULATOR_NAME}" "iPhone 8" 2> /dev/null +xcrun simctl create "${SIMULATOR_NAME}" "iPhone 13" "iOS15.5" 2> /dev/null # Find the UDID of the newly created simulator. SIMULATOR_UDID=`xcrun simctl list --json devices | jq -r ".devices | flatten | .[] | select(.name == \"${SIMULATOR_NAME}\").udid"` # Edit the simulator's .GlobalPreferences.plist to use the Korean language. diff --git a/Sources/Runestone/Documentation.docc/Documentation.md b/Sources/Runestone/Documentation.docc/Documentation.md index 058d6647a..4be7c7771 100644 --- a/Sources/Runestone/Documentation.docc/Documentation.md +++ b/Sources/Runestone/Documentation.docc/Documentation.md @@ -61,12 +61,14 @@ Syntax highlighting is based on GitHub's [Tree-sitter](https://github.com/tree-s - - +- - ``LanguageMode`` - ``PlainTextLanguageMode`` - ``TreeSitterLanguageMode`` - ``TreeSitterLanguage`` - ``TreeSitterLanguageProvider`` - ``SyntaxNode`` +- ``StringSyntaxHighlighter`` ### Indentation diff --git a/Sources/Runestone/Documentation.docc/Extensions/StringSyntaxHighlighter.md b/Sources/Runestone/Documentation.docc/Extensions/StringSyntaxHighlighter.md new file mode 100644 index 000000000..578b6528d --- /dev/null +++ b/Sources/Runestone/Documentation.docc/Extensions/StringSyntaxHighlighter.md @@ -0,0 +1,45 @@ +# ``StringSyntaxHighlighter`` + +## Example + +Create a syntax highlighter by passing a theme and language, and then call the ``StringSyntaxHighlighter/syntaxHighlight(_:)`` method to syntax highlight the provided text. + +```swift +let syntaxHighlighter = StringSyntaxHighlighter( + theme: TomorrowTheme(), + language: .javaScript +) +let attributedString = syntaxHighlighter.syntaxHighlight( + """ + function fibonacci(num) { + if (num <= 1) { + return 1 + } + return fibonacci(num - 1) + fibonacci(num - 2) + } + """ +) +``` + +## Topics + +### Essentials + +- +- ``StringSyntaxHighlighter/syntaxHighlight(_:)`` + +### Initialing the Syntax Highlighter + +- ``StringSyntaxHighlighter/init(theme:language:languageProvider:)`` + +### Configuring the Appearance + +- ``StringSyntaxHighlighter/theme`` +- ``StringSyntaxHighlighter/kern`` +- ``StringSyntaxHighlighter/lineHeightMultiplier`` +- ``StringSyntaxHighlighter/tabLength`` + +### Specifying the Language + +- ``StringSyntaxHighlighter/language`` +- ``StringSyntaxHighlighter/languageProvider`` diff --git a/Sources/Runestone/Documentation.docc/Extensions/TextView.md b/Sources/Runestone/Documentation.docc/Extensions/TextView.md index ddd635211..ffc35b66e 100644 --- a/Sources/Runestone/Documentation.docc/Extensions/TextView.md +++ b/Sources/Runestone/Documentation.docc/Extensions/TextView.md @@ -40,7 +40,6 @@ - ``isLineWrappingEnabled`` - ``lineBreakMode`` -- ``lengthOfInitallyLongestLine`` ### Invisible Characters diff --git a/Sources/Runestone/Documentation.docc/SyntaxHighlightingAString.md b/Sources/Runestone/Documentation.docc/SyntaxHighlightingAString.md new file mode 100644 index 000000000..f812e0f58 --- /dev/null +++ b/Sources/Runestone/Documentation.docc/SyntaxHighlightingAString.md @@ -0,0 +1,48 @@ +# Syntax Highlighting a String + +Learn how to syntax hightlight a string without needing to create a TextView. + +## Overview + +The can be used to syntax highlight a string without needing to create a . + +Before reading this article, make sure that you have follow the guides on and . + + +## Creating an Attributed String + +Create an instance of by supplying the theme containing the colors and fonts to be used for syntax highlighting the text, as well as the language to use when parsing the text. + +```swift +let syntaxHighlighter = StringSyntaxHighlighter( + theme: TomorrowTheme(), + language: .javaScript +) +``` + +If the language has any embedded languages, you will need to pass an object conforming to , which provides the syntax highlighter with additional languages. + +Apply customizations to the syntax highlighter as needed. + +```swift +syntaxHighlighter.kern = 0.3 +syntaxHighlighter.lineHeightMultiplier = 1.2 +syntaxHighlighter.tabLength = 2 +``` + +With the syntax highlighter created and configured, we can syntax highlight the text. + +```swift +let attributedString = syntaxHighlighter.syntaxHighlight( + """ + function fibonacci(num) { + if (num <= 1) { + return 1 + } + return fibonacci(num - 1) + fibonacci(num - 2) + } + """ +) +``` + +The attributed string can be displayed using a UILabel or UITextView. diff --git a/Sources/Runestone/Library/ByteRange.swift b/Sources/Runestone/Library/ByteRange.swift index 88a953311..fb3b6c71e 100644 --- a/Sources/Runestone/Library/ByteRange.swift +++ b/Sources/Runestone/Library/ByteRange.swift @@ -28,7 +28,7 @@ struct ByteRange: Hashable { self.length = ByteCount(utf16Range.length * 2) } - func overlaps(_ otherRange: ByteRange) -> Bool { + func overlaps(_ otherRange: Self) -> Bool { let r1 = location ... location + length let r2 = otherRange.location ... otherRange.location + otherRange.length return r1.overlaps(r2) diff --git a/Sources/Runestone/Library/DefaultStringAttributes.swift b/Sources/Runestone/Library/DefaultStringAttributes.swift new file mode 100644 index 000000000..aab529d42 --- /dev/null +++ b/Sources/Runestone/Library/DefaultStringAttributes.swift @@ -0,0 +1,27 @@ +import Foundation +import UIKit + +struct DefaultStringAttributes { + let textColor: UIColor + let font: UIFont + let kern: CGFloat + let tabWidth: CGFloat + + func apply(to attributedString: NSMutableAttributedString) { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.tabStops = (0 ..< 20).map { index in + NSTextTab(textAlignment: .natural, location: CGFloat(index) * tabWidth) + } + paragraphStyle.defaultTabInterval = tabWidth + let range = NSRange(location: 0, length: attributedString.length) + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: textColor, + .font: font, + .kern: kern as NSNumber, + .paragraphStyle: paragraphStyle + ] + attributedString.beginEditing() + attributedString.setAttributes(attributes, range: range) + attributedString.endEditing() + } +} diff --git a/Sources/Runestone/Library/HairlineLength.swift b/Sources/Runestone/Library/HairlineLength.swift new file mode 100644 index 000000000..e1f42580b --- /dev/null +++ b/Sources/Runestone/Library/HairlineLength.swift @@ -0,0 +1,7 @@ +import UIKit + +#if compiler(<5.9) || !os(visionOS) +let hairlineLength = 1 / UIScreen.main.scale +#else +let hairlineLength: CGFloat = 1 +#endif diff --git a/Sources/Runestone/Library/TabWidthMeasurer.swift b/Sources/Runestone/Library/TabWidthMeasurer.swift new file mode 100644 index 000000000..94bc5b69a --- /dev/null +++ b/Sources/Runestone/Library/TabWidthMeasurer.swift @@ -0,0 +1,12 @@ +import UIKit + +enum TabWidthMeasurer { + static func tabWidth(tabLength: Int, font: UIFont) -> CGFloat { + let str = String(repeating: " ", count: tabLength) + let maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + let options: NSStringDrawingOptions = [.usesFontLeading, .usesLineFragmentOrigin] + let attributes: [NSAttributedString.Key: Any] = [.font: font] + let bounds = str.boundingRect(with: maxSize, options: options, attributes: attributes, context: nil) + return round(bounds.size.width) + } +} diff --git a/Sources/Runestone/Library/UITextInput+Helpers.swift b/Sources/Runestone/Library/UITextInput+Helpers.swift new file mode 100644 index 000000000..db115d56c --- /dev/null +++ b/Sources/Runestone/Library/UITextInput+Helpers.swift @@ -0,0 +1,23 @@ +import UIKit + +#if compiler(>=5.9) + +@available(iOS 17, *) +extension UITextInput where Self: NSObject { + var sbs_textSelectionDisplayInteraction: UITextSelectionDisplayInteraction? { + let interactionAssistantKey = "int" + "ssAnoitcare".reversed() + "istant" + let selectionViewManagerKey = "les_".reversed() + "ection" + "reganaMweiV".reversed() + guard responds(to: Selector(interactionAssistantKey)) else { + return nil + } + guard let interactionAssistant = value(forKey: interactionAssistantKey) as? AnyObject else { + return nil + } + guard interactionAssistant.responds(to: Selector(selectionViewManagerKey)) else { + return nil + } + return interactionAssistant.value(forKey: selectionViewManagerKey) as? UITextSelectionDisplayInteraction + } +} + +#endif diff --git a/Sources/Runestone/Library/UITextSelectionDisplayInteraction+Helpers.swift b/Sources/Runestone/Library/UITextSelectionDisplayInteraction+Helpers.swift new file mode 100644 index 000000000..23b19f8bf --- /dev/null +++ b/Sources/Runestone/Library/UITextSelectionDisplayInteraction+Helpers.swift @@ -0,0 +1,12 @@ +import UIKit + +#if compiler(>=5.9) + +@available(iOS 17, *) +extension UITextSelectionDisplayInteraction { + func sbs_enableCursorBlinks() { + setValue(true, forKey: "rosruc".reversed() + "Blinks") + } +} + +#endif diff --git a/Sources/Runestone/PrivacyInfo.xcprivacy b/Sources/Runestone/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..2009fb7a2 --- /dev/null +++ b/Sources/Runestone/PrivacyInfo.xcprivacy @@ -0,0 +1,14 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyAccessedAPITypes + + + diff --git a/Sources/Runestone/StringSyntaxHighlighter.swift b/Sources/Runestone/StringSyntaxHighlighter.swift new file mode 100644 index 000000000..000122d47 --- /dev/null +++ b/Sources/Runestone/StringSyntaxHighlighter.swift @@ -0,0 +1,105 @@ +import UIKit + +/// Syntax highlights a string. +/// +/// An instance of `StringSyntaxHighlighter` can be used to syntax highlight a string without needing to create a `TextView`. +public final class StringSyntaxHighlighter { + /// The theme to use when syntax highlighting the text. + public var theme: Theme + /// The language to use when parsing the text. + public var language: TreeSitterLanguage + /// Object that can provide embedded languages on demand. A strong reference will be stored to the language provider. + public var languageProvider: TreeSitterLanguageProvider? + /// The number of points by which to adjust kern. + /// + /// The default value is 0 meaning that kerning is disabled. + public var kern: CGFloat = 0 + /// The tab length determines the width of the tab measured in space characers. + /// + /// The default value is 4 meaning that a tab is four spaces wide. + public var tabLength: Int = 4 + /// The line-height is multiplied with the value. + public var lineHeightMultiplier: CGFloat = 1 + + /// Creates an object that can syntax highlight a text. + /// - Parameters: + /// - theme: The theme to use when syntax highlighting the text. + /// - language: The language to use when parsing the text + /// - languageProvider: Object that can provide embedded languages on demand. A strong reference will be stored to the language provider.. + public init( + theme: Theme = DefaultTheme(), + language: TreeSitterLanguage, + languageProvider: TreeSitterLanguageProvider? = nil + ) { + self.theme = theme + self.language = language + self.languageProvider = languageProvider + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Syntax highlights the text using the configured syntax highlighter. + /// - Parameter text: The text to be syntax highlighted. + /// - Returns: An attributed string containing the syntax highlighted text. + public func syntaxHighlight(_ text: String) -> NSAttributedString { + let mutableString = NSMutableString(string: text) + let stringView = StringView(string: mutableString) + let lineManager = LineManager(stringView: stringView) + lineManager.rebuild() + let languageMode = TreeSitterLanguageMode(language: language, languageProvider: languageProvider) + let internalLanguageMode = languageMode.makeInternalLanguageMode( + stringView: stringView, + lineManager: lineManager + ) + internalLanguageMode.parse(mutableString) + let tabWidth = TabWidthMeasurer.tabWidth(tabLength: tabLength, font: theme.font) + let mutableAttributedString = NSMutableAttributedString(string: text) + let defaultAttributes = DefaultStringAttributes( + textColor: theme.textColor, + font: theme.font, + kern: kern, + tabWidth: tabWidth + ) + defaultAttributes.apply(to: mutableAttributedString) + applyLineHeightMultiplier(to: mutableAttributedString) + let byteRange = ByteRange(from: 0, to: text.byteCount) + let syntaxHighlighter = internalLanguageMode.createLineSyntaxHighlighter() + syntaxHighlighter.theme = theme + let syntaxHighlighterInput = LineSyntaxHighlighterInput( + attributedString: mutableAttributedString, + byteRange: byteRange + ) + syntaxHighlighter.syntaxHighlight(syntaxHighlighterInput) + return mutableAttributedString + } +} + +private extension StringSyntaxHighlighter { + private func applyLineHeightMultiplier(to attributedString: NSMutableAttributedString) { + let scaledLineHeight = theme.font.totalLineHeight * lineHeightMultiplier + let mutableParagraphStyle = getMutableParagraphStyle(from: attributedString) + mutableParagraphStyle.lineSpacing = scaledLineHeight - theme.font.totalLineHeight + let range = NSRange(location: 0, length: attributedString.length) + attributedString.beginEditing() + attributedString.removeAttribute(.paragraphStyle, range: range) + attributedString.addAttribute(.paragraphStyle, value: mutableParagraphStyle, range: range) + attributedString.endEditing() + } + + private func getMutableParagraphStyle( + from attributedString: NSMutableAttributedString + ) -> NSMutableParagraphStyle { + guard let attributeValue = attributedString.attribute(.paragraphStyle, at: 0, effectiveRange: nil) else { + return NSMutableParagraphStyle() + } + guard let paragraphStyle = attributeValue as? NSParagraphStyle else { + fatalError("Expected .paragraphStyle attribute to be instance of NSParagraphStyle") + } + guard let mutableParagraphStyle = paragraphStyle.mutableCopy() as? NSMutableParagraphStyle else { + fatalError("Expected mutableCopy() to return an instance of NSMutableParagraphStyle") + } + return mutableParagraphStyle + } +} diff --git a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift index 53523ab5d..b392ec33f 100644 --- a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift +++ b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift @@ -67,7 +67,6 @@ public final class DefaultTheme: Runestone.Theme { } } -#if compiler(>=5.7) @available(iOS 16.0, *) public func highlightedRange(forFoundTextRange foundTextRange: NSRange, ofStyle style: UITextSearchFoundTextStyle) -> HighlightedRange? { switch style { @@ -83,7 +82,6 @@ public final class DefaultTheme: Runestone.Theme { return nil } } -#endif } private extension UIColor { diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index 4ec3db428..eb3549065 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -51,7 +51,6 @@ public protocol Theme: AnyObject { /// /// See for more information on higlight names. func shadow(for highlightName: String) -> NSShadow? -#if compiler(>=5.7) /// Highlighted range for a text range matching a search query. /// /// This function is called when highlighting a search result that was found using the standard find/replace interaction enabled using . @@ -63,7 +62,6 @@ public protocol Theme: AnyObject { /// - Returns: The object used for highlighting the provided text range, or `nil` if the range should not be highlighted. @available(iOS 16, *) func highlightedRange(forFoundTextRange foundTextRange: NSRange, ofStyle style: UITextSearchFoundTextStyle) -> HighlightedRange? -#endif } public extension Theme { @@ -72,11 +70,11 @@ public extension Theme { var pageGuideHairlineWidth: CGFloat { 0.5 } #else var gutterHairlineWidth: CGFloat { - 1 / UIScreen.main.scale + hairlineLength } var pageGuideHairlineWidth: CGFloat { - 1 / UIScreen.main.scale + hairlineLength } #endif @@ -96,7 +94,6 @@ public extension Theme { nil } -#if compiler(>=5.7) @available(iOS 16, *) func highlightedRange(forFoundTextRange foundTextRange: NSRange, ofStyle style: UITextSearchFoundTextStyle) -> HighlightedRange? { switch style { @@ -110,5 +107,4 @@ public extension Theme { return nil } } -#endif } diff --git a/Sources/Runestone/TextView/Appearance/Theme.xcassets/theme_gutter_background.colorset/Contents.json b/Sources/Runestone/TextView/Appearance/Theme.xcassets/theme_gutter_background.colorset/Contents.json index 7175aa037..98ef9c75a 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.xcassets/theme_gutter_background.colorset/Contents.json +++ b/Sources/Runestone/TextView/Appearance/Theme.xcassets/theme_gutter_background.colorset/Contents.json @@ -2,8 +2,13 @@ "colors" : [ { "color" : { - "platform" : "ios", - "reference" : "systemBackgroundColor" + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } }, "idiom" : "universal" }, @@ -15,8 +20,13 @@ } ], "color" : { - "platform" : "ios", - "reference" : "systemBackgroundColor" + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } }, "idiom" : "universal" } diff --git a/Sources/Runestone/TextView/Appearance/Theme.xcassets/theme_gutter_hairline.colorset/Contents.json b/Sources/Runestone/TextView/Appearance/Theme.xcassets/theme_gutter_hairline.colorset/Contents.json index 7175aa037..98ef9c75a 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.xcassets/theme_gutter_hairline.colorset/Contents.json +++ b/Sources/Runestone/TextView/Appearance/Theme.xcassets/theme_gutter_hairline.colorset/Contents.json @@ -2,8 +2,13 @@ "colors" : [ { "color" : { - "platform" : "ios", - "reference" : "systemBackgroundColor" + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } }, "idiom" : "universal" }, @@ -15,8 +20,13 @@ } ], "color" : { - "platform" : "ios", - "reference" : "systemBackgroundColor" + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } }, "idiom" : "universal" } diff --git a/Sources/Runestone/TextView/Core/EditMenuController.swift b/Sources/Runestone/TextView/Core/EditMenuController.swift index ede4ffe98..203811b63 100644 --- a/Sources/Runestone/TextView/Core/EditMenuController.swift +++ b/Sources/Runestone/TextView/Core/EditMenuController.swift @@ -11,24 +11,18 @@ protocol EditMenuControllerDelegate: AnyObject { final class EditMenuController: NSObject { weak var delegate: EditMenuControllerDelegate? -#if compiler(>=5.7) @available(iOS 16, *) private var editMenuInteraction: UIEditMenuInteraction? { _editMenuInteraction as? UIEditMenuInteraction } private var _editMenuInteraction: Any? -#endif func setupEditMenu(in view: UIView) { -#if compiler(>=5.7) if #available(iOS 16, *) { setupEditMenuInteraction(in: view) } else { setupMenuController() } -#else - setupMenuController() -#endif } func presentEditMenu(from view: UIView, forTextIn range: NSRange) { @@ -36,7 +30,6 @@ final class EditMenuController: NSObject { let endCaretRect = caretRect(at: range.location + range.length) let menuWidth = min(endCaretRect.maxX - startCaretRect.minX, view.frame.width) let menuRect = CGRect(x: startCaretRect.minX, y: startCaretRect.minY, width: menuWidth, height: startCaretRect.height) -#if compiler(>=5.7) if #available(iOS 16, *) { let point = CGPoint(x: menuRect.midX, y: menuRect.minY) let configuration = UIEditMenuConfiguration(identifier: nil, sourcePoint: point) @@ -45,9 +38,6 @@ final class EditMenuController: NSObject { } else { UIMenuController.shared.showMenu(from: view, rect: menuRect) } -#else - UIMenuController.shared.showMenu(from: view, rect: menuRect) -#endif } func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? { @@ -59,14 +49,12 @@ final class EditMenuController: NSObject { } private extension EditMenuController { -#if compiler(>=5.7) @available(iOS 16, *) private func setupEditMenuInteraction(in view: UIView) { let editMenuInteraction = UIEditMenuInteraction(delegate: self) _editMenuInteraction = editMenuInteraction view.addInteraction(editMenuInteraction) } -#endif private func setupMenuController() { // This is not necessary starting from iOS 16. @@ -100,12 +88,13 @@ private extension EditMenuController { } } -#if compiler(>=5.7) @available(iOS 16, *) extension EditMenuController: UIEditMenuInteractionDelegate { - func editMenuInteraction(_ interaction: UIEditMenuInteraction, - menuFor configuration: UIEditMenuConfiguration, - suggestedActions: [UIMenuElement]) -> UIMenu? { + func editMenuInteraction( + _ interaction: UIEditMenuInteraction, + menuFor configuration: UIEditMenuConfiguration, + suggestedActions: [UIMenuElement] + ) -> UIMenu? { if let selectedRange = delegate?.selectedRange(for: self), let replaceAction = replaceActionIfAvailable(for: selectedRange) { return UIMenu(children: [replaceAction] + suggestedActions) } else { @@ -113,4 +102,3 @@ extension EditMenuController: UIEditMenuInteractionDelegate { } } } -#endif diff --git a/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift index 3d3a87475..12f45fab2 100644 --- a/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift @@ -3,6 +3,10 @@ import UIKit final class TextInputStringTokenizer: UITextInputStringTokenizer { var lineManager: LineManager var stringView: StringView + // Used to ensure we can workaround bug where multi-stage input, like when entering Korean text + // does not work properly. If we do not treat navigation between word boundies as a special case then + // navigating with Shift + Option + Arrow Keys followed by Shift + Arrow Keys will not work correctly. + var didCallPositionFromPositionToWordBoundary = false private let lineControllerStorage: LineControllerStorage private var newlineCharacters: [Character] { @@ -28,12 +32,6 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { } } - override func isPosition(_ position: UITextPosition, - withinTextUnit granularity: UITextGranularity, - inDirection direction: UITextDirection) -> Bool { - super.isPosition(position, withinTextUnit: granularity, inDirection: direction) - } - override func position(from position: UITextPosition, toBoundary granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextPosition? { @@ -47,12 +45,6 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { return super.position(from: position, toBoundary: granularity, inDirection: direction) } } - - override func rangeEnclosingPosition(_ position: UITextPosition, - with granularity: UITextGranularity, - inDirection direction: UITextDirection) -> UITextRange? { - super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) - } } // MARK: - Lines @@ -129,7 +121,7 @@ private extension TextInputStringTokenizer { private func isPosition(_ position: UITextPosition, atParagraphBoundaryInDirection direction: UITextDirection) -> Bool { // I can't seem to make Ctrl+A, Ctrl+E, Cmd+Left, and Cmd+Right work properly if this function returns anything but false. // I've tried various ways of determining the paragraph boundary but UIKit doesn't seem to be happy with anything I come up with ultimately leading to incorrect keyboard navigation. I haven't yet found any drawbacks to returning false in all cases. - return false + false } private func position(from position: UITextPosition, toParagraphBoundaryInDirection direction: UITextDirection) -> UITextPosition? { @@ -218,6 +210,7 @@ private extension TextInputStringTokenizer { guard let indexedPosition = position as? IndexedPosition else { return nil } + didCallPositionFromPositionToWordBoundary = true let location = indexedPosition.index let alphanumerics = CharacterSet.alphanumerics if direction.isForward { diff --git a/Sources/Runestone/TextView/Core/TextInputView.swift b/Sources/Runestone/TextView/Core/TextInputView.swift index a512f836c..a7b2f2c10 100644 --- a/Sources/Runestone/TextView/Core/TextInputView.swift +++ b/Sources/Runestone/TextView/Core/TextInputView.swift @@ -47,6 +47,16 @@ final class TextInputView: UIView, UITextInput { shouldNotifyInputDelegate = true didCallPositionFromPositionInDirectionWithOffset = false } + // This is a consequence of our workaround that ensures multi-stage input, such as when entering Korean, + // works correctly. The workaround causes bugs when selecting words using Shift + Option + Arrow Keys + // followed by Shift + Arrow Keys if we do not treat it as a special case. + // The consequence of not having this workaround is that Shift + Arrow Keys may adjust the wrong end of + // the selected text when followed by navigating between word boundaries usign Shift + Option + Arrow Keys. + if customTokenizer.didCallPositionFromPositionToWordBoundary && !didCallDeleteBackward { + shouldNotifyInputDelegate = true + customTokenizer.didCallPositionFromPositionToWordBoundary = false + } + didCallDeleteBackward = false notifyInputDelegateAboutSelectionChangeInLayoutSubviews = !shouldNotifyInputDelegate if shouldNotifyInputDelegate { inputDelegate?.selectionWillChange(self) @@ -594,6 +604,7 @@ final class TextInputView: UIView, UITextInput { private var notifyInputDelegateAboutSelectionChangeInLayoutSubviews = false private var notifyDelegateAboutSelectionChangeInLayoutSubviews = false private var didCallPositionFromPositionInDirectionWithOffset = false + private var didCallDeleteBackward = false private var hasDeletedTextWithPendingLayoutSubviews = false private var preserveUndoStackWhenSettingString = false private var cancellables: [AnyCancellable] = [] @@ -1116,6 +1127,7 @@ extension TextInputView { } func deleteBackward() { + didCallDeleteBackward = true guard let selectedRange = markedRange ?? selectedRange, selectedRange.length > 0 else { return } @@ -1611,9 +1623,7 @@ extension TextInputView: LineControllerStorageDelegate { // MARK: - LineControllerDelegate extension TextInputView: LineControllerDelegate { func lineSyntaxHighlighter(for lineController: LineController) -> LineSyntaxHighlighter? { - let syntaxHighlighter = languageMode.createLineSyntaxHighlighter() - syntaxHighlighter.kern = kern - return syntaxHighlighter + languageMode.createLineSyntaxHighlighter() } func lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ lineController: LineController) { diff --git a/Sources/Runestone/TextView/Core/TextView.swift b/Sources/Runestone/TextView/Core/TextView.swift index b2259e915..69bfdcfd4 100644 --- a/Sources/Runestone/TextView/Core/TextView.swift +++ b/Sources/Runestone/TextView/Core/TextView.swift @@ -185,7 +185,7 @@ open class TextView: UIScrollView { } } set { - textInputView.selectedRange = newValue + textInputView.selectedTextRange = IndexedRange(newValue) } } /// The current selection range of the text view as a UITextRange. @@ -197,8 +197,7 @@ open class TextView: UIScrollView { textInputView.selectedTextRange = newValue } } - -#if !os(visionOS) + #if compiler(<5.9) || !os(visionOS) /// The custom input accessory view to display when the receiver becomes the first responder. override public var inputAccessoryView: UIView? { get { @@ -212,12 +211,13 @@ open class TextView: UIScrollView { _inputAccessoryView = newValue } } + #endif + #if compiler(<5.9) || !os(visionOS) /// The input assistant to use when configuring the keyboard's shortcuts bar. override public var inputAssistantItem: UITextInputAssistantItem { textInputView.inputAssistantItem } -#endif - + #endif /// Returns a Boolean value indicating whether this object can become the first responder. override public var canBecomeFirstResponder: Bool { !textInputView.isFirstResponder && isEditable @@ -525,12 +525,6 @@ open class TextView: UIScrollView { } } } - /// The length of the line that was longest when opening the document. - /// - /// This will return nil if the line is no longer available. The value will not be kept updated as the text is changed. The value can be used to determine if a document contains a very long line in which case the performance may be degraded when editing the line. - public var lengthOfInitallyLongestLine: Int? { - textInputView.lineManager.initialLongestLine?.data.totalLength - } /// Ranges in the text to be highlighted. The color defined by the background will be drawen behind the text. public var highlightedRanges: [HighlightedRange] { get { @@ -574,7 +568,6 @@ open class TextView: UIScrollView { } /// When enabled the text view will present a menu with actions actions such as Copy and Replace after navigating to a highlighted range. public var showMenuAfterNavigatingToHighlightedRange = true -#if compiler(>=5.7) /// A boolean value that enables a text view’s built-in find interaction. /// /// After enabling the find interaction, use [`presentFindNavigator(showingReplace:)`](https://developer.apple.com/documentation/uikit/uifindinteraction/3975832-presentfindnavigator) on to present the find navigator. @@ -596,23 +589,17 @@ open class TextView: UIScrollView { public var findInteraction: UIFindInteraction? { textSearchingHelper.findInteraction } -#endif private let textInputView: TextInputView private let editableTextInteraction = UITextInteraction(for: .editable) private let nonEditableTextInteraction = UITextInteraction(for: .nonEditable) -#if compiler(>=5.7) @available(iOS 16.0, *) private var editMenuInteraction: UIEditMenuInteraction? { _editMenuInteraction as? UIEditMenuInteraction } private var _editMenuInteraction: Any? -#endif private let tapGestureRecognizer = QuickTapGestureRecognizer() private var _inputAccessoryView: UIView? -#if !os(visionOS) - private let _inputAssistantItem = UITextInputAssistantItem() -#endif private var isPerformingNonEditableTextInteraction = false private var delegateAllowsEditingToBegin: Bool { guard isEditable else { @@ -1250,6 +1237,12 @@ private extension TextView { isInputAccessoryViewEnabled = true textInputView.removeInteraction(nonEditableTextInteraction) textInputView.addInteraction(editableTextInteraction) + #if compiler(>=5.9) + if #available(iOS 17, *) { + // Workaround a bug where the caret does not appear until the user taps again on iOS 17 (FB12622609). + textInputView.sbs_textSelectionDisplayInteraction?.isActivated = true + } + #endif } } @@ -1402,6 +1395,12 @@ extension TextView: TextInputViewDelegate { if !view.viewHierarchyContainsCaret && self.editableTextInteraction.view != nil { view.removeInteraction(self.editableTextInteraction) view.addInteraction(self.editableTextInteraction) + #if compiler(>=5.9) + if #available(iOS 17, *) { + self.textInputView.sbs_textSelectionDisplayInteraction?.isActivated = true + self.textInputView.sbs_textSelectionDisplayInteraction?.sbs_enableCursorBlinks() + } + #endif } } } diff --git a/Sources/Runestone/TextView/Core/TextViewState.swift b/Sources/Runestone/TextView/Core/TextViewState.swift index 0d3df3b7c..3be44e3f4 100644 --- a/Sources/Runestone/TextView/Core/TextViewState.swift +++ b/Sources/Runestone/TextView/Core/TextViewState.swift @@ -14,13 +14,16 @@ public final class TextViewState { /// The information provided by the detected strategy can be used to update the ``TextView/indentStrategy`` on the text view to align with the existing strategy in a text. public private(set) var detectedIndentStrategy: DetectedIndentStrategy = .unknown - /// Line endings detected in the dtext. + /// Line endings detected in the text. /// /// The information pvoided by the detected line endings can be used to update the ``TextView/lineEndings`` on the text view to align with the existing line endings in a text. /// /// The value is `nil` if the line ending cannot be detected. public private(set) var detectedLineEndings: LineEnding? + /// The length of the longest line. + public private(set) var lengthOfLongestLine: Int? + /// Creates state that can be passed to an instance of ``TextView``. /// - Parameters: /// - text: The text to display in the text view. @@ -60,6 +63,7 @@ private extension TextViewState { lineManager.estimatedLineHeight = theme.font.totalLineHeight lineManager.rebuild() languageMode.parse(nsString) + lengthOfLongestLine = lineManager.initialLongestLine?.data.totalLength detectedIndentStrategy = languageMode.detectIndentStrategy() let lineEndingDetector = LineEndingDetector(lineManager: lineManager, stringView: stringView) detectedLineEndings = lineEndingDetector.detect() diff --git a/Sources/Runestone/TextView/Indent/IndentController.swift b/Sources/Runestone/TextView/Indent/IndentController.swift index e4c0fba89..22d92dc12 100644 --- a/Sources/Runestone/TextView/Indent/IndentController.swift +++ b/Sources/Runestone/TextView/Indent/IndentController.swift @@ -30,12 +30,7 @@ final class IndentController { if let tabWidth = _tabWidth { return tabWidth } else { - let str = String(repeating: " ", count: indentStrategy.tabLength) - let maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) - let options: NSStringDrawingOptions = [.usesFontLeading, .usesLineFragmentOrigin] - let attributes: [NSAttributedString.Key: Any] = [.font: indentFont] - let bounds = str.boundingRect(with: maxSize, options: options, attributes: attributes, context: nil) - let tabWidth = round(bounds.size.width) + let tabWidth = TabWidthMeasurer.tabWidth(tabLength: indentStrategy.tabLength, font: indentFont) if tabWidth != _tabWidth { _tabWidth = tabWidth delegate?.indentControllerDidUpdateTabWidth(self) diff --git a/Sources/Runestone/TextView/Indent/IndentStrategy.swift b/Sources/Runestone/TextView/Indent/IndentStrategy.swift index b0b4aaa2b..67532c30e 100644 --- a/Sources/Runestone/TextView/Indent/IndentStrategy.swift +++ b/Sources/Runestone/TextView/Indent/IndentStrategy.swift @@ -2,7 +2,7 @@ import Foundation /// Strategy to use when indenting text. public enum IndentStrategy: Equatable { - /// Indent using tabs. The length specified length is used to determine the width of the tab measured in space characers. + /// Indent using tabs. The specified length is used to determine the width of the tab measured in space characers. case tab(length: Int) /// Indent using a number of spaces. case space(length: Int) diff --git a/Sources/Runestone/TextView/LineController/LineController.swift b/Sources/Runestone/TextView/LineController/LineController.swift index ed3036d39..a837661ec 100644 --- a/Sources/Runestone/TextView/LineController/LineController.swift +++ b/Sources/Runestone/TextView/LineController/LineController.swift @@ -64,7 +64,7 @@ final class LineController { var kern: CGFloat = 0 { didSet { if kern != oldValue { - syntaxHighlighter?.kern = kern + isDefaultAttributesInvalid = true } } } @@ -240,25 +240,20 @@ private extension LineController { private func updateDefaultAttributesIfNecessary() { if isDefaultAttributesInvalid { if let input = createLineSyntaxHighlightInput() { - syntaxHighlighter?.setDefaultAttributes(on: input.attributedString) + let defaultStringAttributes = DefaultStringAttributes( + textColor: theme.textColor, + font: theme.font, + kern: kern, + tabWidth: tabWidth + ) + defaultStringAttributes.apply(to: input.attributedString) } - updateParagraphStyle() isDefaultAttributesInvalid = false isSyntaxHighlightingInvalid = true isTypesetterInvalid = true } } - private func updateParagraphStyle() { - if let attributedString = attributedString { - let paragraphStyle = NSMutableParagraphStyle() - paragraphStyle.tabStops = [] - paragraphStyle.defaultTabInterval = tabWidth - let range = NSRange(location: 0, length: attributedString.length) - attributedString.addAttribute(.paragraphStyle, value: paragraphStyle, range: range) - } - } - private func updateTypesetterIfNecessary() { if isTypesetterInvalid { lineFragmentTree.reset(rootValue: 0, rootData: LineFragmentNodeData(lineFragment: nil)) diff --git a/Sources/Runestone/TextView/LineController/LineFragmentSelectionRect.swift b/Sources/Runestone/TextView/LineController/LineFragmentSelectionRect.swift index d9bdac05a..17e2d75d7 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentSelectionRect.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentSelectionRect.swift @@ -5,10 +5,4 @@ struct LineFragmentSelectionRect { let rect: CGRect let range: NSRange let extendsBeyondEnd: Bool - - init(rect: CGRect, range: NSRange, extendsBeyondEnd: Bool) { - self.rect = rect - self.range = range - self.extendsBeyondEnd = extendsBeyondEnd - } } diff --git a/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift b/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift index 9f2d5ecf5..8f0319855 100644 --- a/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift +++ b/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift @@ -23,24 +23,8 @@ final class LineSyntaxHighlighterInput { protocol LineSyntaxHighlighter: AnyObject { typealias AsyncCallback = (Result) -> Void var theme: Theme { get set } - var kern: CGFloat { get set } var canHighlight: Bool { get } - func setDefaultAttributes(on attributedString: NSMutableAttributedString) func syntaxHighlight(_ input: LineSyntaxHighlighterInput) func syntaxHighlight(_ input: LineSyntaxHighlighterInput, completion: @escaping AsyncCallback) func cancel() } - -extension LineSyntaxHighlighter { - func setDefaultAttributes(on attributedString: NSMutableAttributedString) { - let entireRange = NSRange(location: 0, length: attributedString.length) - let attributes: [NSAttributedString.Key: Any] = [ - .foregroundColor: theme.textColor, - .font: theme.font, - .kern: kern as NSNumber - ] - attributedString.beginEditing() - attributedString.setAttributes(attributes, range: entireRange) - attributedString.endEditing() - } -} diff --git a/Sources/Runestone/TextView/PageGuide/PageGuideView.swift b/Sources/Runestone/TextView/PageGuide/PageGuideView.swift index b8968ca8c..ff514c144 100644 --- a/Sources/Runestone/TextView/PageGuide/PageGuideView.swift +++ b/Sources/Runestone/TextView/PageGuide/PageGuideView.swift @@ -1,18 +1,13 @@ import UIKit final class PageGuideView: UIView { -#if os(visionOS) - var hairlineWidth: CGFloat = 0.5 -#else - var hairlineWidth: CGFloat = 1 / UIScreen.main.scale { + var hairlineWidth: CGFloat { didSet { if hairlineWidth != oldValue { setNeedsLayout() } } } -#endif - var hairlineColor: UIColor? { get { hairlineView.backgroundColor @@ -25,6 +20,7 @@ final class PageGuideView: UIView { private let hairlineView = UIView() override init(frame: CGRect) { + self.hairlineWidth = hairlineLength super.init(frame: frame) isUserInteractionEnabled = false hairlineView.isUserInteractionEnabled = false diff --git a/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift b/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift index 47ab89680..66c11cdbd 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift @@ -14,17 +14,17 @@ struct ParsedReplacementString: Equatable { case text(TextParameters) case placeholder(PlaceholderParameters) - static func text(_ text: String) -> Component { + static func text(_ text: String) -> Self { let parameters = TextParameters(text: text) return .text(parameters) } - static func placeholder(_ index: Int) -> Component { + static func placeholder(_ index: Int) -> Self { let parameters = PlaceholderParameters(modifiers: [], index: index) return .placeholder(parameters) } - static func placeholder(modifiers: [StringModifier], index: Int) -> Component { + static func placeholder(modifiers: [StringModifier], index: Int) -> Self { let parameters = PlaceholderParameters(modifiers: modifiers, index: index) return .placeholder(parameters) } diff --git a/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift b/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift index 51e60c8b6..2732acc5d 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift @@ -31,7 +31,7 @@ enum StringModifier { } } - static func string(byApplying modifiers: [StringModifier], to string: String) -> String { + static func string(byApplying modifiers: [Self], to string: String) -> String { guard !modifiers.isEmpty else { return string } diff --git a/Sources/Runestone/TextView/SearchAndReplace/TextPreview.swift b/Sources/Runestone/TextView/SearchAndReplace/TextPreview.swift index 8bf8d8f51..839911797 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/TextPreview.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/TextPreview.swift @@ -34,10 +34,22 @@ public final class TextPreview { /// /// This is potentially an expensive operation and should ideally be done on-demand. public func prepare() { - forEachRangeInLineController { lineController, range in + let resultingAttributedString = NSMutableAttributedString() + var remainingLength = previewRange.length + for lineController in lineControllers { + let lineLocation = lineController.line.location + let lineLength = lineController.line.data.totalLength + let location = max(previewRange.location - lineLocation, 0) + let length = min(remainingLength, lineLength) + let range = NSRange(location: location, length: length) lineController.prepareToDisplayString(toLocation: range.upperBound, syntaxHighlightAsynchronously: false) + if let attributedString = lineController.attributedString { + let substring = attributedString.attributedSubstring(from: range) + resultingAttributedString.append(substring) + remainingLength -= range.length + } } - updateAttributedString() + attributedString = resultingAttributedString } /// Invalidate the syntax highlighted attributed string. @@ -60,29 +72,3 @@ public final class TextPreview { } } } - -private extension TextPreview { - private func updateAttributedString() { - let resultingAttributedString = NSMutableAttributedString() - forEachRangeInLineController { lineController, range in - if let attributedString = lineController.attributedString, range.upperBound < attributedString.length { - let substring = attributedString.attributedSubstring(from: range) - resultingAttributedString.append(substring) - } - } - attributedString = resultingAttributedString - } - - private func forEachRangeInLineController(_ handler: (LineController, NSRange) -> Void) { - var remainingLength = previewRange.length - for lineController in lineControllers { - let lineLocation = lineController.line.location - let lineLength = lineController.line.data.totalLength - let location = max(previewRange.location - lineLocation, 0) - let length = min(remainingLength, lineLength) - let range = NSRange(location: location, length: length) - remainingLength -= length - handler(lineController, range) - } - } -} diff --git a/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift index 3fa1cfad2..ff2e28a09 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift @@ -2,7 +2,6 @@ import UIKit final class UITextSearchingHelper: NSObject { weak var textView: TextView? -#if compiler(>=5.7) var isFindInteractionEnabled = false { didSet { if isFindInteractionEnabled != oldValue { @@ -17,7 +16,6 @@ final class UITextSearchingHelper: NSObject { @available(iOS 16, *) var findInteraction: UIFindInteraction? { get { - // swiftlint:disable implicit_return guard let _findInteraction = _findInteraction else { return nil } @@ -25,14 +23,12 @@ final class UITextSearchingHelper: NSObject { fatalError("Expected _findInteraction to be of type \(UIFindInteraction.self)") } return findInteraction - // swiftlint:enable implicit_return } set { _findInteraction = newValue } } private var _findInteraction: Any? -#endif private let queue = OperationQueue() private var _textView: TextView { @@ -54,7 +50,6 @@ final class UITextSearchingHelper: NSObject { } } -#if compiler(>=5.7) @available(iOS 16, *) extension UITextSearchingHelper: UITextSearching { var supportsTextReplacement: Bool { @@ -201,4 +196,3 @@ private extension SearchQuery.MatchMethod { } } } -#endif diff --git a/Sources/Runestone/TreeSitter/TreeSitterNode.swift b/Sources/Runestone/TreeSitter/TreeSitterNode.swift index e7a89865b..b22375344 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterNode.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterNode.swift @@ -58,7 +58,7 @@ final class TreeSitterNode { return Self(node: node) } - func child(at index: Int) -> TreeSitterNode? { + func child(at index: Int) -> Self? { if index < childCount { let node = ts_node_child(rawValue, UInt32(index)) return Self(node: node) diff --git a/Tests/RunestoneTests/TextInputStringTokenizerTests.swift b/Tests/RunestoneTests/TextInputStringTokenizerTests.swift index 4b47c0f67..10703ec17 100644 --- a/Tests/RunestoneTests/TextInputStringTokenizerTests.swift +++ b/Tests/RunestoneTests/TextInputStringTokenizerTests.swift @@ -253,7 +253,7 @@ extension TextInputStringTokenizerTests { private extension TextInputStringTokenizerTests { private var sampleText: String { // swiftlint:disable line_length - return """ + """ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras commodo pretium lorem et scelerisque. Sed urna massa, eleifend vel suscipit et, finibus ut nisi. Praesent ullamcorper justo ut lectus faucibus venenatis. Suspendisse lobortis libero sed odio iaculis, quis blandit ante accumsan. Quisque sed hendrerit diam. Quisque ut enim ligula. diff --git a/UITests/HostUITests/KoreanInputTests.swift b/UITests/HostUITests/KoreanInputTests.swift index 792aafca8..98f799e6e 100644 --- a/UITests/HostUITests/KoreanInputTests.swift +++ b/UITests/HostUITests/KoreanInputTests.swift @@ -95,37 +95,6 @@ final class KoreanInputTests: XCTestCase { XCTAssertEqual(app.textView?.value as? String, "\"어\"") } - func testInsertingKoreanCharactersBelowStringContainingKoreanLetters() throws { - let app = XCUIApplication().disablingTextPersistance() - app.launch() - app.textView?.tap() - app.keys["ㅇ"].tap() - app.keys["ㅓ"].tap() - app.keys["ㅇ"].tap() - app.keys["ㅓ"].tap() - app.keys["ㅇ"].tap() - app.keys["ㅓ"].tap() - app.buttons["Return"].tap() - app.keys["more"].tap() - app.keys["\""].tap() - app.keys["more"].tap() - app.keys["ㅇ"].tap() - app.keys["ㅓ"].tap() - app.keys["ㅇ"].tap() - app.keys["ㅓ"].tap() - app.keys["ㅇ"].tap() - app.keys["ㅓ"].tap() - app.tap(at: CGPoint(x: 100, y: 100)) - app.buttons["Return"].tap() - app.keys["ㅇ"].tap() - app.keys["ㅓ"].tap() - app.keys["ㅇ"].tap() - app.keys["ㅓ"].tap() - app.keys["ㅇ"].tap() - app.keys["ㅓ"].tap() - XCTAssertEqual(app.textView?.value as? String, "어어어\n\"어어어\"\n어어어") - } - func testInsertingKoreanCharactersInTextWithCRLFLineEndings() throws { let app = XCUIApplication().disablingTextPersistance().usingCRLFLineEndings() app.launch() diff --git a/UITests/HostUITests/XCUIApplication+Helpers.swift b/UITests/HostUITests/XCUIApplication+Helpers.swift index a95acd23f..1299b25b3 100644 --- a/UITests/HostUITests/XCUIApplication+Helpers.swift +++ b/UITests/HostUITests/XCUIApplication+Helpers.swift @@ -10,13 +10,6 @@ extension XCUIApplication { scrollViews.children(matching: .textView).element } - func tap(at point: CGPoint) { - let normalized = coordinate(withNormalizedOffset: .zero) - let offset = CGVector(dx: point.x, dy: point.y) - let coordinate = normalized.withOffset(offset) - coordinate.tap() - } - func disablingTextPersistance() -> Self { var newLaunchEnvironment = launchEnvironment newLaunchEnvironment[EnvironmentKey.disableTextPersistance] = "1" diff --git a/tree-sitter b/tree-sitter deleted file mode 160000 index b268e412a..000000000 --- a/tree-sitter +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b268e412ad4848380166af153300464e5a1cf83f From 48fac3d3f5eac5ee83f7d01623a83613b372391c Mon Sep 17 00:00:00 2001 From: Adrian Schoenig Date: Wed, 3 Apr 2024 14:17:03 +1100 Subject: [PATCH 32/35] Revert a change --- Sources/Runestone/TextView/Appearance/Theme.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index eb3549065..286efbd79 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -65,10 +65,6 @@ public protocol Theme: AnyObject { } public extension Theme { -#if os(visionOS) - var gutterHairlineWidth: CGFloat { 0.5 } - var pageGuideHairlineWidth: CGFloat { 0.5 } -#else var gutterHairlineWidth: CGFloat { hairlineLength } @@ -76,7 +72,6 @@ public extension Theme { var pageGuideHairlineWidth: CGFloat { hairlineLength } -#endif var markedTextBackgroundCornerRadius: CGFloat { 0 From 36d91bfd44170935461618316e1433a1246c44fc Mon Sep 17 00:00:00 2001 From: Miguel de Icaza Date: Mon, 15 Apr 2024 04:02:46 -0400 Subject: [PATCH 33/35] Add a lock around parse, to prevent concurrent access to the underlying parse tree. (#367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add a lock around parse, as we can have both the OperationQueue-based parse and this parse happen at the same time * Fixes SwiftLint violation --------- Co-authored-by: Simon Støvring --- .../Internal/TreeSitter/TreeSitterInternalLanguageMode.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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)) { From 81e23a9a9e90d52b5b407f60d378731875aaea6b Mon Sep 17 00:00:00 2001 From: Miguel de Icaza Date: Mon, 13 May 2024 00:34:05 -0400 Subject: [PATCH 34/35] Allow TextLocation to be created (#372) --- Sources/Runestone/TextView/Navigation/TextLocation.swift | 6 ++++++ 1 file changed, 6 insertions(+) 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 From 1fad339aab99cf2136ce6bf8c32da3265b2e85e5 Mon Sep 17 00:00:00 2001 From: Eli Perkins Date: Mon, 10 Jun 2024 17:50:12 -0600 Subject: [PATCH 35/35] Fix build with Xcode 16 (#375) Prior to this, Xcode 16 was unable to infer the type of the variable (while Xcode 15 was), so the lookup to `reversed()` was ambiguous. This adds `String` as the inferred type, so that the call to `reversed()` is no longer ambiguous. --- Sources/Runestone/Library/UITextInput+Helpers.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 }