From 8ccbe815df7db98d892d6d2f24998199bce6958e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 27 Jan 2023 12:02:00 +0100 Subject: [PATCH 001/232] Removes usage of UIKit when compiling for Mac --- Package.swift | 5 +- Sources/Runestone/Library/Caret.swift | 4 +- .../MultiPlatform/MultiPlatformColor.swift | 30 + .../MultiPlatformEdgeInsets.swift | 15 + .../MultiPlatform/MultiPlatformFont.swift | 23 + .../MultiPlatform/MultiPlatformView.swift | 39 + .../Runestone/Library/UIFont+Helpers.swift | 7 - .../Runestone/Library/ViewReuseQueue.swift | 11 +- .../Library/{ => iOS}/KeyboardObserver.swift | 2 + .../{ => iOS}/QuickTapGestureRecognizer.swift | 2 + .../{ => iOS}/UIScrollView+Helpers.swift | 2 + .../TextView/Appearance/DefaultTheme.swift | 70 +- .../Runestone/TextView/Appearance/Theme.swift | 50 +- .../Contents.json | 26 + .../Contents.json | 26 + .../TextView/Core/ContentSizeService.swift | 6 +- .../TextView/Core/LayoutManager.swift | 38 +- .../TextView/Core/LineFragmentView.swift | 37 +- .../Core/Mac/TextInputClientView.swift | 54 + .../TextView/Core/Mac/TextView_Mac.swift | 40 + .../Runestone/TextView/Core/TextView.swift | 1497 ----------------- .../Core/{ => iOS}/EditMenuController.swift | 2 + .../Core/{ => iOS}/FloatingCaretView.swift | 2 + .../Core/{ => iOS}/IndexedPosition.swift | 2 + .../Core/{ => iOS}/IndexedRange.swift | 2 + .../{ => iOS}/LineMovementController.swift | 2 + .../{ => iOS}/TextInputStringTokenizer.swift | 2 + .../Core/{ => iOS}/TextInputView.swift | 2 + .../TextView/Core/iOS/TextView_iOS.swift | 1497 +++++++++++++++++ .../Gutter/GutterBackgroundView.swift | 21 +- .../TextView/Gutter/GutterWidthService.swift | 7 +- .../TextView/Gutter/LineNumberView.swift | 73 +- .../HighlightNavigationController.swift | 1 - .../TextView/Highlight/HighlightedRange.swift | 6 +- .../Highlight/HighlightedRangeFragment.swift | 17 +- .../TextView/Indent/IndentController.swift | 9 +- .../InvisibleCharacterConfiguration.swift | 10 +- .../LineController/LineController.swift | 6 + .../LineFragmentController.swift | 4 +- .../LineController/LineFragmentRenderer.swift | 37 +- .../PageGuide/PageGuideController.swift | 11 +- .../TextView/PageGuide/PageGuideView.swift | 33 +- .../{ => iOS}/UITextSearchingHelper.swift | 2 + .../TreeSitterSyntaxHighlightToken.swift | 11 +- .../TreeSitterSyntaxHighlighter.swift | 27 +- .../TextSelection/CaretRectService.swift | 4 +- .../TextSelection/SelectionRectService.swift | 5 +- .../{ => iOS}/TextSelectionRect.swift | 24 + .../TextInputStringTokenizerTests.swift | 2 + 49 files changed, 2121 insertions(+), 1684 deletions(-) create mode 100644 Sources/Runestone/Library/MultiPlatform/MultiPlatformColor.swift create mode 100644 Sources/Runestone/Library/MultiPlatform/MultiPlatformEdgeInsets.swift create mode 100644 Sources/Runestone/Library/MultiPlatform/MultiPlatformFont.swift create mode 100644 Sources/Runestone/Library/MultiPlatform/MultiPlatformView.swift delete mode 100644 Sources/Runestone/Library/UIFont+Helpers.swift rename Sources/Runestone/Library/{ => iOS}/KeyboardObserver.swift (99%) rename Sources/Runestone/Library/{ => iOS}/QuickTapGestureRecognizer.swift (97%) rename Sources/Runestone/Library/{ => iOS}/UIScrollView+Helpers.swift (96%) create mode 100644 Sources/Runestone/TextView/Core/Mac/TextInputClientView.swift create mode 100644 Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift delete mode 100644 Sources/Runestone/TextView/Core/TextView.swift rename Sources/Runestone/TextView/Core/{ => iOS}/EditMenuController.swift (99%) rename Sources/Runestone/TextView/Core/{ => iOS}/FloatingCaretView.swift (90%) rename Sources/Runestone/TextView/Core/{ => iOS}/IndexedPosition.swift (87%) rename Sources/Runestone/TextView/Core/{ => iOS}/IndexedRange.swift (96%) rename Sources/Runestone/TextView/Core/{ => iOS}/LineMovementController.swift (99%) rename Sources/Runestone/TextView/Core/{ => iOS}/TextInputStringTokenizer.swift (99%) rename Sources/Runestone/TextView/Core/{ => iOS}/TextInputView.swift (99%) create mode 100644 Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift rename Sources/Runestone/TextView/SearchAndReplace/{ => iOS}/UITextSearchingHelper.swift (99%) rename Sources/Runestone/TextView/TextSelection/{ => iOS}/TextSelectionRect.swift (60%) rename Tests/RunestoneTests/{ => iOS}/TextInputStringTokenizerTests.swift (99%) diff --git a/Package.swift b/Package.swift index 3e810ddbf..851a7cf59 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -7,7 +7,8 @@ let package = Package( name: "Runestone", defaultLocalization: "en", platforms: [ - .iOS(.v14) + .iOS(.v14), + .macOS(.v13) ], products: [ .library(name: "Runestone", targets: ["Runestone"]) diff --git a/Sources/Runestone/Library/Caret.swift b/Sources/Runestone/Library/Caret.swift index 819d0c6f2..8fde18870 100644 --- a/Sources/Runestone/Library/Caret.swift +++ b/Sources/Runestone/Library/Caret.swift @@ -1,9 +1,9 @@ -import UIKit +import CoreGraphics enum Caret { static let width: CGFloat = 2 - static func defaultHeight(for font: UIFont?) -> CGFloat { + static func defaultHeight(for font: MultiPlatformFont?) -> CGFloat { return font?.lineHeight ?? 15 } } diff --git a/Sources/Runestone/Library/MultiPlatform/MultiPlatformColor.swift b/Sources/Runestone/Library/MultiPlatform/MultiPlatformColor.swift new file mode 100644 index 000000000..b1316e3d3 --- /dev/null +++ b/Sources/Runestone/Library/MultiPlatform/MultiPlatformColor.swift @@ -0,0 +1,30 @@ +#if os(macOS) +import AppKit +public typealias MultiPlatformColor = NSColor +#else +import UIKit +public typealias MultiPlatformColor = UIColor +#endif + +extension MultiPlatformColor { + convenience init(themeColorNamed name: String) { + let fullName = "theme_" + name + #if os(iOS) + self.init(named: fullName, in: .module, compatibleWith: nil)! + #else + self.init(named: fullName, bundle: .module)! + #endif + } +} + +#if os(macOS) +extension NSColor { + static var label: NSColor { + .labelColor + } + + static var systemFill: NSColor { + .systemGray.withAlphaComponent(0.1) + } +} +#endif diff --git a/Sources/Runestone/Library/MultiPlatform/MultiPlatformEdgeInsets.swift b/Sources/Runestone/Library/MultiPlatform/MultiPlatformEdgeInsets.swift new file mode 100644 index 000000000..3e2057da6 --- /dev/null +++ b/Sources/Runestone/Library/MultiPlatform/MultiPlatformEdgeInsets.swift @@ -0,0 +1,15 @@ +#if os(macOS) +import AppKit +public typealias MultiPlatformEdgeInsets = NSEdgeInsets +#else +import UIKit +public typealias MultiPlatformEdgeInsets = UIEdgeInsets +#endif + +#if os(macOS) +extension NSEdgeInsets { + static var zero: NSEdgeInsets { + NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + } +} +#endif diff --git a/Sources/Runestone/Library/MultiPlatform/MultiPlatformFont.swift b/Sources/Runestone/Library/MultiPlatform/MultiPlatformFont.swift new file mode 100644 index 000000000..15049012a --- /dev/null +++ b/Sources/Runestone/Library/MultiPlatform/MultiPlatformFont.swift @@ -0,0 +1,23 @@ +#if os(macOS) +import AppKit +public typealias MultiPlatformFont = NSFont +public typealias MultiPlatformFontDescriptor = NSFontDescriptor +#else +import UIKit +public typealias MultiPlatformFont = UIFont +public typealias MultiPlatformFontDescriptor = UIFontDescriptor +#endif + +extension MultiPlatformFont { + var totalLineHeight: CGFloat { + return ascender + abs(descender) + leading + } +} + +#if os(macOS) +extension NSFont { + var lineHeight: CGFloat { + ceil(ascender + abs(descender) + leading) + } +} +#endif diff --git a/Sources/Runestone/Library/MultiPlatform/MultiPlatformView.swift b/Sources/Runestone/Library/MultiPlatform/MultiPlatformView.swift new file mode 100644 index 000000000..66bfb3263 --- /dev/null +++ b/Sources/Runestone/Library/MultiPlatform/MultiPlatformView.swift @@ -0,0 +1,39 @@ +#if os(macOS) +import AppKit +public typealias MultiPlatformView = NSView +#else +import UIKit +public typealias MultiPlatformView = UIView +#endif + +#if os(macOS) +extension NSView { + var backgroundColor: NSColor? { + get { + if let backgroundColor = layer?.backgroundColor { + return NSColor(cgColor: backgroundColor) + } else { + return nil + } + } + set { + if backgroundColor != nil { + wantsLayer = true + } + layer?.backgroundColor = newValue?.cgColor + } + } + + func setNeedsDisplay() { + setNeedsDisplay(bounds) + } + + func setNeedsLayout() { + needsLayout = true + } +} + +func UIGraphicsGetCurrentContext() -> CGContext? { + NSGraphicsContext.current?.cgContext +} +#endif diff --git a/Sources/Runestone/Library/UIFont+Helpers.swift b/Sources/Runestone/Library/UIFont+Helpers.swift deleted file mode 100644 index b1257cc17..000000000 --- a/Sources/Runestone/Library/UIFont+Helpers.swift +++ /dev/null @@ -1,7 +0,0 @@ -import UIKit - -extension UIFont { - var totalLineHeight: CGFloat { - return ascender + abs(descender) + leading - } -} diff --git a/Sources/Runestone/Library/ViewReuseQueue.swift b/Sources/Runestone/Library/ViewReuseQueue.swift index 53ce6907c..e305273df 100644 --- a/Sources/Runestone/Library/ViewReuseQueue.swift +++ b/Sources/Runestone/Library/ViewReuseQueue.swift @@ -1,5 +1,3 @@ -import UIKit - protocol ReusableView { func prepareForReuse() } @@ -8,21 +6,26 @@ extension ReusableView { func prepareForReuse() {} } -final class ViewReuseQueue { +final class ViewReuseQueue { private(set) var visibleViews: [Key: View] = [:] private var queuedViews: Set = [] + init() { + #if os(iOS) NotificationCenter.default.addObserver( self, selector: #selector(clearMemory), name: UIApplication.didReceiveMemoryWarningNotification, object: nil) + #endif } deinit { + #if os(iOS) NotificationCenter.default.removeObserver(self) + #endif } func enqueueViews(withKeys keys: Set) { @@ -58,7 +61,9 @@ final class ViewReuseQueue { } } + #if os(iOS) @objc private func clearMemory() { queuedViews.removeAll() } + #endif } diff --git a/Sources/Runestone/Library/KeyboardObserver.swift b/Sources/Runestone/Library/iOS/KeyboardObserver.swift similarity index 99% rename from Sources/Runestone/Library/KeyboardObserver.swift rename to Sources/Runestone/Library/iOS/KeyboardObserver.swift index 5d6538c8b..082480162 100644 --- a/Sources/Runestone/Library/KeyboardObserver.swift +++ b/Sources/Runestone/Library/iOS/KeyboardObserver.swift @@ -1,3 +1,4 @@ +#if os(iOS) import UIKit protocol KeyboardObserverDelegate: AnyObject { @@ -107,3 +108,4 @@ private extension KeyboardObserver { } } } +#endif diff --git a/Sources/Runestone/Library/QuickTapGestureRecognizer.swift b/Sources/Runestone/Library/iOS/QuickTapGestureRecognizer.swift similarity index 97% rename from Sources/Runestone/Library/QuickTapGestureRecognizer.swift rename to Sources/Runestone/Library/iOS/QuickTapGestureRecognizer.swift index 5da5466f1..5232195b6 100644 --- a/Sources/Runestone/Library/QuickTapGestureRecognizer.swift +++ b/Sources/Runestone/Library/iOS/QuickTapGestureRecognizer.swift @@ -1,3 +1,4 @@ +#if os(iOS) import UIKit final class QuickTapGestureRecognizer: UITapGestureRecognizer { @@ -33,3 +34,4 @@ final class QuickTapGestureRecognizer: UITapGestureRecognizer { cancelTimer = nil } } +#endif diff --git a/Sources/Runestone/Library/UIScrollView+Helpers.swift b/Sources/Runestone/Library/iOS/UIScrollView+Helpers.swift similarity index 96% rename from Sources/Runestone/Library/UIScrollView+Helpers.swift rename to Sources/Runestone/Library/iOS/UIScrollView+Helpers.swift index 8231e9297..10c1d5b93 100644 --- a/Sources/Runestone/Library/UIScrollView+Helpers.swift +++ b/Sources/Runestone/Library/iOS/UIScrollView+Helpers.swift @@ -1,3 +1,4 @@ +#if os(iOS) import UIKit extension UIScrollView { @@ -11,3 +12,4 @@ extension UIScrollView { return CGPoint(x: maxX, y: maxY) } } +#endif diff --git a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift index 53523ab5d..f341a66b5 100644 --- a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift +++ b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift @@ -1,58 +1,56 @@ -import UIKit - /// Default theme used by Runestone when no other theme has been set. public final class DefaultTheme: Runestone.Theme { - public let font: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular) - public let textColor = UIColor(themeColorNamed: "foreground") - public let gutterBackgroundColor = UIColor(themeColorNamed: "gutter_background") - public let gutterHairlineColor = UIColor(themeColorNamed: "gutter_hairline") - public let lineNumberColor = UIColor(themeColorNamed: "line_number") - public let lineNumberFont: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular) - public let selectedLineBackgroundColor = UIColor(themeColorNamed: "current_line") - public let selectedLinesLineNumberColor = UIColor(themeColorNamed: "line_number_current_line") - public let selectedLinesGutterBackgroundColor = UIColor(themeColorNamed: "gutter_background") - public let invisibleCharactersColor = UIColor(themeColorNamed: "invisible_characters") - public let pageGuideHairlineColor = UIColor(themeColorNamed: "page_guide_hairline") - public let pageGuideBackgroundColor = UIColor(themeColorNamed: "page_guide_background") - public let markedTextBackgroundColor = UIColor(themeColorNamed: "marked_text") - public let selectionColor = UIColor(themeColorNamed: "selection") + public let font: MultiPlatformFont = .monospacedSystemFont(ofSize: 14, weight: .regular) + public let textColor = MultiPlatformColor(themeColorNamed: "foreground") + public let gutterBackgroundColor = MultiPlatformColor(themeColorNamed: "gutter_background") + public let gutterHairlineColor = MultiPlatformColor(themeColorNamed: "gutter_hairline") + public let lineNumberColor = MultiPlatformColor(themeColorNamed: "line_number") + public let lineNumberFont: MultiPlatformFont = .monospacedSystemFont(ofSize: 14, weight: .regular) + public let selectedLineBackgroundColor = MultiPlatformColor(themeColorNamed: "current_line") + public let selectedLinesLineNumberColor = MultiPlatformColor(themeColorNamed: "line_number_current_line") + public let selectedLinesGutterBackgroundColor = MultiPlatformColor(themeColorNamed: "gutter_background") + public let invisibleCharactersColor = MultiPlatformColor(themeColorNamed: "invisible_characters") + public let pageGuideHairlineColor = MultiPlatformColor(themeColorNamed: "page_guide_hairline") + public let pageGuideBackgroundColor = MultiPlatformColor(themeColorNamed: "page_guide_background") + public let markedTextBackgroundColor = MultiPlatformColor(themeColorNamed: "marked_text") + public let selectionColor = MultiPlatformColor(themeColorNamed: "selection") public init() {} // swiftlint:disable:next cyclomatic_complexity - public func textColor(for highlightName: String) -> UIColor? { + public func textColor(for highlightName: String) -> MultiPlatformColor? { guard let highlightName = HighlightName(highlightName) else { return nil } switch highlightName { case .comment: - return UIColor(themeColorNamed: "comment") + return MultiPlatformColor(themeColorNamed: "comment") case .constantBuiltin: - return UIColor(themeColorNamed: "constant_builtin") + return MultiPlatformColor(themeColorNamed: "constant_builtin") case .constantCharacter: - return UIColor(themeColorNamed: "constant_character") + return MultiPlatformColor(themeColorNamed: "constant_character") case .constructor: - return UIColor(themeColorNamed: "constructor") + return MultiPlatformColor(themeColorNamed: "constructor") case .function: - return UIColor(themeColorNamed: "function") + return MultiPlatformColor(themeColorNamed: "function") case .keyword: - return UIColor(themeColorNamed: "keyword") + return MultiPlatformColor(themeColorNamed: "keyword") case .number: - return UIColor(themeColorNamed: "number") + return MultiPlatformColor(themeColorNamed: "number") case .property: - return UIColor(themeColorNamed: "property") + return MultiPlatformColor(themeColorNamed: "property") case .string: - return UIColor(themeColorNamed: "string") + return MultiPlatformColor(themeColorNamed: "string") case .type: - return UIColor(themeColorNamed: "type") + return MultiPlatformColor(themeColorNamed: "type") case .variable: return nil case .variableBuiltin: - return UIColor(themeColorNamed: "variable_builtin") + return MultiPlatformColor(themeColorNamed: "variable_builtin") case .operator: - return UIColor(themeColorNamed: "operator") + return MultiPlatformColor(themeColorNamed: "operator") case .punctuation: - return UIColor(themeColorNamed: "punctuation") + return MultiPlatformColor(themeColorNamed: "punctuation") } } @@ -67,15 +65,15 @@ public final class DefaultTheme: Runestone.Theme { } } -#if compiler(>=5.7) +#if compiler(>=5.7) && os(iOS) @available(iOS 16.0, *) public func highlightedRange(forFoundTextRange foundTextRange: NSRange, ofStyle style: UITextSearchFoundTextStyle) -> HighlightedRange? { switch style { case .found: - let color = UIColor(themeColorNamed: "search_match_found") + let color = MultiPlatformColor(themeColorNamed: "search_match_found") return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 2) case .highlighted: - let color = UIColor(themeColorNamed: "search_match_highlighted") + let color = MultiPlatformColor(themeColorNamed: "search_match_highlighted") return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 2) case .normal: return nil @@ -85,9 +83,3 @@ public final class DefaultTheme: Runestone.Theme { } #endif } - -private extension UIColor { - convenience init(themeColorNamed name: String) { - self.init(named: "theme_" + name, in: .module, compatibleWith: nil)! - } -} diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index ebdfa4bbc..fe208ddf8 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -1,46 +1,52 @@ +#if os(macOS) +import AppKit +#endif +import CoreGraphics +#if os(iOS) import UIKit +#endif /// Fonts and colors to be used by a `TextView`. public protocol Theme: AnyObject { /// Default font of text in the text view. - var font: UIFont { get } + var font: MultiPlatformFont { get } /// Default color of text in the text view. - var textColor: UIColor { get } + var textColor: MultiPlatformColor { get } /// Background color of the gutter containing line numbers. - var gutterBackgroundColor: UIColor { get } + var gutterBackgroundColor: MultiPlatformColor { get } /// Color of the hairline next to the gutter containing line numbers. - var gutterHairlineColor: UIColor { get } + var gutterHairlineColor: MultiPlatformColor { get } /// Width of the hairline next to the gutter containing line numbers. var gutterHairlineWidth: CGFloat { get } /// Color of the line numbers in the gutter. - var lineNumberColor: UIColor { get } + var lineNumberColor: MultiPlatformColor { get } /// Font of the line nubmers in the gutter. - var lineNumberFont: UIFont { get } + var lineNumberFont: MultiPlatformFont { get } /// Background color of the selected line. - var selectedLineBackgroundColor: UIColor { get } + var selectedLineBackgroundColor: MultiPlatformColor { get } /// Color of the line number of the selected line. - var selectedLinesLineNumberColor: UIColor { get } + var selectedLinesLineNumberColor: MultiPlatformColor { get } /// Background color of the gutter for selected lines. - var selectedLinesGutterBackgroundColor: UIColor { get } + var selectedLinesGutterBackgroundColor: MultiPlatformColor { get } /// Color of invisible characters, i.e. dots, spaces and line breaks. - var invisibleCharactersColor: UIColor { get } + var invisibleCharactersColor: MultiPlatformColor { get } /// Color of the hairline next to the page guide. - var pageGuideHairlineColor: UIColor { get } + var pageGuideHairlineColor: MultiPlatformColor { get } /// Background color of the page guide. - var pageGuideBackgroundColor: UIColor { get } + var pageGuideBackgroundColor: MultiPlatformColor { get } /// Background color of marked text. Text will be marked when writing certain languages, for example Chinese and Japanese. - var markedTextBackgroundColor: UIColor { get } + var markedTextBackgroundColor: MultiPlatformColor { get } /// Corner radius of the background of marked text. Text will be marked when writing certain languages, for example Chinese and Japanese. /// A value of zero or less means that the background will not have rounded corners. Defaults to 0. var markedTextBackgroundCornerRadius: CGFloat { get } /// Color of text matching the capture sequence. /// /// See for more information on higlight names. - func textColor(for highlightName: String) -> UIColor? + func textColor(for highlightName: String) -> MultiPlatformColor? /// Font of text matching the capture sequence. /// /// See for more information on higlight names. - func font(for highlightName: String) -> UIFont? + func font(for highlightName: String) -> MultiPlatformFont? /// Traits of text matching the capture sequence. /// /// See for more information on higlight names. @@ -49,7 +55,7 @@ public protocol Theme: AnyObject { /// /// See for more information on higlight names. func shadow(for highlightName: String) -> NSShadow? -#if compiler(>=5.7) +#if compiler(>=5.7) && os(iOS) /// 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 . @@ -66,18 +72,26 @@ public protocol Theme: AnyObject { public extension Theme { var gutterHairlineWidth: CGFloat { + #if os(iOS) return 1 / UIScreen.main.scale + #else + return 1 / NSScreen.main!.backingScaleFactor + #endif } var pageGuideHairlineWidth: CGFloat { + #if os(iOS) return 1 / UIScreen.main.scale + #else + return 1 / NSScreen.main!.backingScaleFactor + #endif } var markedTextBackgroundCornerRadius: CGFloat { return 0 } - func font(for highlightName: String) -> UIFont? { + func font(for highlightName: String) -> MultiPlatformFont? { return nil } @@ -89,7 +103,7 @@ public extension Theme { return nil } -#if compiler(>=5.7) +#if compiler(>=5.7) && os(iOS) @available(iOS 16, *) func highlightedRange(forFoundTextRange foundTextRange: NSRange, ofStyle style: UITextSearchFoundTextStyle) -> HighlightedRange? { switch style { 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..cea60bd6e 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 @@ -19,6 +19,32 @@ "reference" : "systemBackgroundColor" }, "idiom" : "universal" + }, + { + "color" : { + "color-space" : "extended-gray", + "components" : { + "alpha" : "1.000", + "white" : "1.000" + } + }, + "idiom" : "mac" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "extended-gray", + "components" : { + "alpha" : "1.000", + "white" : "0.000" + } + }, + "idiom" : "mac" } ], "info" : { 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..cea60bd6e 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 @@ -19,6 +19,32 @@ "reference" : "systemBackgroundColor" }, "idiom" : "universal" + }, + { + "color" : { + "color-space" : "extended-gray", + "components" : { + "alpha" : "1.000", + "white" : "1.000" + } + }, + "idiom" : "mac" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "extended-gray", + "components" : { + "alpha" : "1.000", + "white" : "0.000" + } + }, + "idiom" : "mac" } ], "info" : { diff --git a/Sources/Runestone/TextView/Core/ContentSizeService.swift b/Sources/Runestone/TextView/Core/ContentSizeService.swift index 577f6bc82..eec110123 100644 --- a/Sources/Runestone/TextView/Core/ContentSizeService.swift +++ b/Sources/Runestone/TextView/Core/ContentSizeService.swift @@ -1,9 +1,9 @@ import Combine -import UIKit +import Foundation final class ContentSizeService { - var safeAreaInset: UIEdgeInsets = .zero - var textContainerInset: UIEdgeInsets = .zero + var safeAreaInset: MultiPlatformEdgeInsets = .zero + var textContainerInset: MultiPlatformEdgeInsets = .zero var scrollViewWidth: CGFloat = 0 { didSet { if scrollViewWidth != oldValue && isLineWrappingEnabled { diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index 6ee8f1f8f..e17ac36ba 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -1,20 +1,22 @@ -// swiftlint:disable file_length -import UIKit +import CoreGraphics +import Foundation +import QuartzCore +// swiftlint:disable file_length protocol LayoutManagerDelegate: AnyObject { func layoutManager(_ layoutManager: LayoutManager, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) } final class LayoutManager { weak var delegate: LayoutManagerDelegate? - weak var gutterParentView: UIView? { + weak var gutterParentView: MultiPlatformView? { didSet { if gutterParentView != oldValue { setupViewHierarchy() } } } - weak var textInputView: UIView? { + weak var textInputView: MultiPlatformView? { didSet { if textInputView != oldValue { setupViewHierarchy() @@ -82,8 +84,8 @@ final class LayoutManager { } var isLineWrappingEnabled = true /// Spacing around the text. The left-side spacing defines the distance between the text and the gutter. - var textContainerInset: UIEdgeInsets = .zero - var safeAreaInsets: UIEdgeInsets = .zero + var textContainerInset: MultiPlatformEdgeInsets = .zero + var safeAreaInsets: MultiPlatformEdgeInsets = .zero var selectedRange: NSRange? { didSet { if selectedRange != oldValue { @@ -110,15 +112,15 @@ final class LayoutManager { } // MARK: - Views - let gutterContainerView = UIView() + let gutterContainerView = MultiPlatformView() private var lineFragmentViewReuseQueue = ViewReuseQueue() private var lineNumberLabelReuseQueue = ViewReuseQueue() private var visibleLineIDs: Set = [] - private let linesContainerView = UIView() + private let linesContainerView = MultiPlatformView() private let gutterBackgroundView = GutterBackgroundView() - private let lineNumbersContainerView = UIView() - private let gutterSelectionBackgroundView = UIView() - private let lineSelectionBackgroundView = UIView() + private let lineNumbersContainerView = MultiPlatformView() + private let gutterSelectionBackgroundView = MultiPlatformView() + private let lineSelectionBackgroundView = MultiPlatformView() // MARK: - Sizing private var leadingLineSpacing: CGFloat { @@ -167,15 +169,18 @@ final class LayoutManager { self.caretRectService = caretRectService self.selectionRectService = selectionRectService self.highlightService = highlightService + #if os(iOS) self.linesContainerView.isUserInteractionEnabled = false self.lineNumbersContainerView.isUserInteractionEnabled = false self.gutterContainerView.isUserInteractionEnabled = false self.gutterBackgroundView.isUserInteractionEnabled = false self.gutterSelectionBackgroundView.isUserInteractionEnabled = false self.lineSelectionBackgroundView.isUserInteractionEnabled = false + #endif self.updateShownViews() - let memoryWarningNotificationName = UIApplication.didReceiveMemoryWarningNotification - NotificationCenter.default.addObserver(self, selector: #selector(clearMemory), name: memoryWarningNotificationName, object: nil) + #if os(iOS) + subscribeToMemoryWarningNotification() + #endif } func redisplayVisibleLines() { @@ -555,8 +560,15 @@ private extension LayoutManager { } // MARK: - Memory Management +#if os(iOS) private extension LayoutManager { + private func subscribeToMemoryWarningNotification() { + let memoryWarningNotificationName = UIApplication.didReceiveMemoryWarningNotification + NotificationCenter.default.addObserver(self, selector: #selector(clearMemory), name: memoryWarningNotificationName, object: nil) + } + @objc private func clearMemory() { lineControllerStorage.removeAllLineControllers(exceptLinesWithID: visibleLineIDs) } } +#endif diff --git a/Sources/Runestone/TextView/Core/LineFragmentView.swift b/Sources/Runestone/TextView/Core/LineFragmentView.swift index 439e1063b..69424212b 100644 --- a/Sources/Runestone/TextView/Core/LineFragmentView.swift +++ b/Sources/Runestone/TextView/Core/LineFragmentView.swift @@ -1,6 +1,11 @@ +#if os(macOS) +import AppKit +#endif +#if os(iOS) import UIKit +#endif -final class LineFragmentView: UIView, ReusableView { +final class LineFragmentView: MultiPlatformView, ReusableView { var renderer: LineFragmentRenderer? { didSet { if renderer !== oldValue { @@ -16,26 +21,50 @@ final class LineFragmentView: UIView, ReusableView { } } - private var isRenderInvalid = true - init() { super.init(frame: .zero) backgroundColor = .clear + #if os(iOS) isUserInteractionEnabled = false + #endif } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + #if os(iOS) override func draw(_ rect: CGRect) { super.draw(rect) + _drawRect() + } + #else + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + _drawRect() + } + #endif + + #if os(iOS) + func prepareForReuse() { + _prepareForReuse() + } + #else + override func prepareForReuse() { + super.prepareForReuse() + _prepareForReuse() + } + #endif +} + +private extension LineFragmentView { + private func _drawRect() { if let context = UIGraphicsGetCurrentContext() { renderer?.draw(to: context, inCanvasOfSize: bounds.size) } } - func prepareForReuse() { + private func _prepareForReuse() { renderer = nil } } diff --git a/Sources/Runestone/TextView/Core/Mac/TextInputClientView.swift b/Sources/Runestone/TextView/Core/Mac/TextInputClientView.swift new file mode 100644 index 000000000..97f4b21c4 --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextInputClientView.swift @@ -0,0 +1,54 @@ +#if os(macOS) +import AppKit + +final class TextInputClientView: NSView, NSTextInputClient { + override var acceptsFirstResponder: Bool { + return true + } + + override func doCommand(by selector: Selector) { + print(selector) + } + + func insertText(_ string: Any, replacementRange: NSRange) { + print(string) + } + + func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + + } + + func unmarkText() { + + } + + func selectedRange() -> NSRange { + NSRange(location: 0, length: 0) + } + + func markedRange() -> NSRange { + NSRange(location: 0, length: 0) + } + + func hasMarkedText() -> Bool { + false + } + + func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { + nil + } + + func validAttributesForMarkedText() -> [NSAttributedString.Key] { + [] + } + + func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { + .zero + } + + func characterIndex(for point: NSPoint) -> Int { + 0 + } +} + +#endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift new file mode 100644 index 000000000..35b5a5992 --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -0,0 +1,40 @@ +#if os(macOS) +import AppKit + +public final class TextView: NSView { + private let textInputClientView: TextInputClientView = { + let this = TextInputClientView() + this.translatesAutoresizingMaskIntoConstraints = false + return this + }() + + init() { + super.init(frame: .zero) + wantsLayer = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func keyDown(with event: NSEvent) { + NSCursor.setHiddenUntilMouseMoves(true) + let didInputContextHandleEvent = inputContext?.handleEvent(event) ?? false + if !didInputContextHandleEvent { + super.keyDown(with: event) + } + } +} + +private extension TextView { + private func setupTextInputClientView() { + addSubview(textInputClientView) + NSLayoutConstraint.activate([ + textInputClientView.leadingAnchor.constraint(equalTo: leadingAnchor), + textInputClientView.trailingAnchor.constraint(equalTo: trailingAnchor), + textInputClientView.topAnchor.constraint(equalTo: topAnchor), + textInputClientView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } +} +#endif diff --git a/Sources/Runestone/TextView/Core/TextView.swift b/Sources/Runestone/TextView/Core/TextView.swift deleted file mode 100644 index ed29b57ff..000000000 --- a/Sources/Runestone/TextView/Core/TextView.swift +++ /dev/null @@ -1,1497 +0,0 @@ -// swiftlint:disable file_length type_body_length -import CoreText -import UIKit - -/// A type similiar to UITextView with features commonly found in code editors. -/// -/// `TextView` is a performant implementation of a text view with features such as showing line numbers, searching for text and replacing results, syntax highlighting, showing invisible characters and more. -/// -/// The type does not subclass `UITextView` but its interface is kept close to `UITextView`. -/// -/// When initially configuring the `TextView` with a theme, a language and the text to be shown, it is recommended to use the ``setState(_:addUndoAction:)`` function. -/// The function takes an instance of ``TextViewState`` as input which can be created on a background queue to avoid blocking the main queue while doing the initial parse of a text. -open class TextView: UIScrollView { - /// Delegate to receive callbacks for events triggered by the editor. - public weak var editorDelegate: TextViewDelegate? - /// Whether the text view is in a state where the contents can be edited. - public private(set) var isEditing = false { - didSet { - if isEditing != oldValue { - textInputView.isEditing = isEditing - } - } - } - /// The text that the text view displays. - public var text: String { - get { - return textInputView.string as String - } - set { - textInputView.string = newValue as NSString - contentSize = preferredContentSize - } - } - /// A Boolean value that indicates whether the text view is editable. - public var isEditable = true { - didSet { - if isEditable != oldValue && !isEditable && isEditing { - resignFirstResponder() - textInputViewDidEndEditing(textInputView) - } - } - } - /// A Boolean value that indicates whether the text view is selectable. - public var isSelectable = true { - didSet { - if isSelectable != oldValue { - textInputView.isUserInteractionEnabled = isSelectable - if !isSelectable && isEditing { - resignFirstResponder() - textInputView.clearSelection() - textInputViewDidEndEditing(textInputView) - } - } - } - } - /// Colors and fonts to be used by the editor. - public var theme: Theme { - get { - return textInputView.theme - } - set { - textInputView.theme = newValue - } - } - /// The autocorrection style for the text view. - public var autocorrectionType: UITextAutocorrectionType { - get { - return textInputView.autocorrectionType - } - set { - textInputView.autocorrectionType = newValue - } - } - /// The autocapitalization style for the text view. - public var autocapitalizationType: UITextAutocapitalizationType { - get { - return textInputView.autocapitalizationType - } - set { - textInputView.autocapitalizationType = newValue - } - } - /// The spell-checking style for the text view. - public var smartQuotesType: UITextSmartQuotesType { - get { - return textInputView.smartQuotesType - } - set { - textInputView.smartQuotesType = newValue - } - } - /// The configuration state for smart dashes. - public var smartDashesType: UITextSmartDashesType { - get { - return textInputView.smartDashesType - } - set { - textInputView.smartDashesType = newValue - } - } - /// The configuration state for the smart insertion and deletion of space characters. - public var smartInsertDeleteType: UITextSmartInsertDeleteType { - get { - return textInputView.smartInsertDeleteType - } - set { - textInputView.smartInsertDeleteType = newValue - } - } - /// The spell-checking style for the text object. - public var spellCheckingType: UITextSpellCheckingType { - get { - return textInputView.spellCheckingType - } - set { - textInputView.spellCheckingType = newValue - } - } - /// The keyboard type for the text view. - public var keyboardType: UIKeyboardType { - get { - return textInputView.keyboardType - } - set { - textInputView.keyboardType = newValue - } - } - /// The appearance style of the keyboard for the text view. - public var keyboardAppearance: UIKeyboardAppearance { - get { - return textInputView.keyboardAppearance - } - set { - textInputView.keyboardAppearance = newValue - } - } - /// The display of the return key. - public var returnKeyType: UIReturnKeyType { - get { - return textInputView.returnKeyType - } - set { - textInputView.returnKeyType = newValue - } - } - /// Returns the undo manager used by the text view. - override public var undoManager: UndoManager? { - return textInputView.undoManager - } - /// The color of the insertion point. This can be used to control the color of the caret. - public var insertionPointColor: UIColor { - get { - return textInputView.insertionPointColor - } - set { - textInputView.insertionPointColor = newValue - } - } - /// The color of the selection bar. It is most common to set this to the same color as the color used for the insertion point. - public var selectionBarColor: UIColor { - get { - return textInputView.selectionBarColor - } - set { - textInputView.selectionBarColor = newValue - } - } - /// The color of the selection highlight. It is most common to set this to the same color as the color used for the insertion point. - public var selectionHighlightColor: UIColor { - get { - return textInputView.selectionHighlightColor - } - set { - textInputView.selectionHighlightColor = newValue - } - } - /// The current selection range of the text view. - public var selectedRange: NSRange { - get { - if let selectedRange = textInputView.selectedRange { - return selectedRange - } else { - // UITextView returns the end of the document for the selectedRange by default. - return NSRange(location: textInputView.string.length, length: 0) - } - } - set { - textInputView.selectedRange = newValue - } - } - /// The current selection range of the text view as a UITextRange. - public var selectedTextRange: UITextRange? { - get { - return textInputView.selectedTextRange - } - set { - textInputView.selectedTextRange = newValue - } - } - /// The custom input accessory view to display when the receiver becomes the first responder. - override public var inputAccessoryView: UIView? { - get { - if isInputAccessoryViewEnabled { - return _inputAccessoryView - } else { - return nil - } - } - set { - _inputAccessoryView = newValue - } - } - /// The input assistant to use when configuring the keyboard's shortcuts bar. - override public var inputAssistantItem: UITextInputAssistantItem { - return textInputView.inputAssistantItem - } - /// Returns a Boolean value indicating whether this object can become the first responder. - override public var canBecomeFirstResponder: Bool { - return !textInputView.isFirstResponder && isEditable - } - /// The text view's background color. - override public var backgroundColor: UIColor? { - get { - return textInputView.backgroundColor - } - set { - super.backgroundColor = newValue - textInputView.backgroundColor = newValue - } - } - /// The point at which the origin of the content view is offset from the origin of the scroll view. - override public var contentOffset: CGPoint { - didSet { - if contentOffset != oldValue { - textInputView.viewport = CGRect(origin: contentOffset, size: frame.size) - } - } - } - /// Character pairs are used by the editor to automatically insert a trailing character when the user types the leading character. - /// - /// Common usages of this includes the \" character to surround strings and { } to surround a scope. - public var characterPairs: [CharacterPair] { - get { - return textInputView.characterPairs - } - set { - textInputView.characterPairs = newValue - } - } - /// Determines what should happen to the trailing component of a character pair when deleting the leading component. Defaults to `disabled` meaning that nothing will happen. - public var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode { - get { - return textInputView.characterPairTrailingComponentDeletionMode - } - set { - textInputView.characterPairTrailingComponentDeletionMode = newValue - } - } - /// Enable to show line numbers in the gutter. - public var showLineNumbers: Bool { - get { - return textInputView.showLineNumbers - } - set { - textInputView.showLineNumbers = newValue - } - } - /// Enable to show highlight the selected lines. The selection is only shown in the gutter when multiple lines are selected. - public var lineSelectionDisplayType: LineSelectionDisplayType { - get { - return textInputView.lineSelectionDisplayType - } - set { - textInputView.lineSelectionDisplayType = newValue - } - } - /// The text view renders invisible tabs when enabled. The `tabsSymbol` is used to render tabs. - public var showTabs: Bool { - get { - return textInputView.showTabs - } - set { - textInputView.showTabs = newValue - } - } - /// The text view renders invisible spaces when enabled. - /// - /// he `spaceSymbol` is used to render spaces. - public var showSpaces: Bool { - get { - return textInputView.showSpaces - } - set { - textInputView.showSpaces = newValue - } - } - /// The text view renders invisible spaces when enabled. - /// - /// The `nonBreakingSpaceSymbol` is used to render spaces. - public var showNonBreakingSpaces: Bool { - get { - return textInputView.showNonBreakingSpaces - } - set { - textInputView.showNonBreakingSpaces = newValue - } - } - /// The text view renders invisible line breaks when enabled. - /// - /// The `lineBreakSymbol` is used to render line breaks. - public var showLineBreaks: Bool { - get { - return textInputView.showLineBreaks - } - set { - textInputView.showLineBreaks = newValue - } - } - /// The text view renders invisible soft line breaks when enabled. - /// - /// The `softLineBreakSymbol` is used to render line breaks. These line breaks are typically represented by the U+2028 unicode character. Runestone does not provide any key commands for inserting these but supports rendering them. - public var showSoftLineBreaks: Bool { - get { - return textInputView.showSoftLineBreaks - } - set { - textInputView.showSoftLineBreaks = newValue - } - } - /// Symbol used to display tabs. - /// - /// The value is only used when invisible tab characters is enabled. The default is ▸. - /// - /// Common characters for this symbol include ▸, ⇥, ➜, ➞, and ❯. - public var tabSymbol: String { - get { - return textInputView.tabSymbol - } - set { - textInputView.tabSymbol = newValue - } - } - /// Symbol used to display spaces. - /// - /// The value is only used when showing invisible space characters is enabled. The default is ·. - /// - /// Common characters for this symbol include ·, •, and _. - public var spaceSymbol: String { - get { - return textInputView.spaceSymbol - } - set { - textInputView.spaceSymbol = newValue - } - } - /// Symbol used to display non-breaking spaces. - /// - /// The value is only used when showing invisible space characters is enabled. The default is ·. - /// - /// Common characters for this symbol include ·, •, and _. - public var nonBreakingSpaceSymbol: String { - get { - return textInputView.nonBreakingSpaceSymbol - } - set { - textInputView.nonBreakingSpaceSymbol = newValue - } - } - /// Symbol used to display line break. - /// - /// The value is only used when showing invisible line break characters is enabled. The default is ¬. - /// - /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. - public var lineBreakSymbol: String { - get { - return textInputView.lineBreakSymbol - } - set { - textInputView.lineBreakSymbol = newValue - } - } - /// Symbol used to display soft line breaks. - /// - /// The value is only used when showing invisible soft line break characters is enabled. The default is ¬. - /// - /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. - public var softLineBreakSymbol: String { - get { - return textInputView.softLineBreakSymbol - } - set { - textInputView.softLineBreakSymbol = newValue - } - } - /// The strategy used when indenting text. - public var indentStrategy: IndentStrategy { - get { - return textInputView.indentStrategy - } - set { - textInputView.indentStrategy = newValue - } - } - /// The amount of padding before the line numbers inside the gutter. - public var gutterLeadingPadding: CGFloat { - get { - return textInputView.gutterLeadingPadding - } - set { - textInputView.gutterLeadingPadding = newValue - } - } - /// The amount of padding after the line numbers inside the gutter. - public var gutterTrailingPadding: CGFloat { - get { - return textInputView.gutterTrailingPadding - } - set { - textInputView.gutterTrailingPadding = newValue - } - } - /// The minimum amount of characters to use for width calculation inside the gutter. - public var gutterMinimumCharacterCount: Int { - get { - return textInputView.gutterMinimumCharacterCount - } - set { - textInputView.gutterMinimumCharacterCount = newValue - } - } - /// The amount of spacing surrounding the lines. - public var textContainerInset: UIEdgeInsets { - get { - return textInputView.textContainerInset - } - set { - textInputView.textContainerInset = newValue - } - } - /// When line wrapping is disabled, users can scroll the text view horizontally to see the entire line. - /// - /// Line wrapping is enabled by default. - public var isLineWrappingEnabled: Bool { - get { - return textInputView.isLineWrappingEnabled - } - set { - textInputView.isLineWrappingEnabled = newValue - } - } - /// Line break mode for text view. The default value is .byWordWrapping meaning that wrapping occurs on word boundaries. - public var lineBreakMode: LineBreakMode { - get { - return textInputView.lineBreakMode - } - set { - textInputView.lineBreakMode = newValue - } - } - /// Width of the gutter. - public var gutterWidth: CGFloat { - return textInputView.gutterWidth - } - /// The line-height is multiplied with the value. - public var lineHeightMultiplier: CGFloat { - get { - return textInputView.lineHeightMultiplier - } - set { - textInputView.lineHeightMultiplier = newValue - } - } - /// The number of points by which to adjust kern. The default value is 0 meaning that kerning is disabled. - public var kern: CGFloat { - get { - return textInputView.kern - } - set { - textInputView.kern = newValue - } - } - /// The text view shows a page guide when enabled. Use `pageGuideColumn` to specify the location of the page guide. - public var showPageGuide: Bool { - get { - return textInputView.showPageGuide - } - set { - textInputView.showPageGuide = newValue - } - } - /// Specifies the location of the page guide. Use `showPageGuide` to specify if the page guide should be shown. - public var pageGuideColumn: Int { - get { - return textInputView.pageGuideColumn - } - set { - textInputView.pageGuideColumn = newValue - } - } - /// Automatically scrolls the text view to show the caret when typing or moving the caret. - public var isAutomaticScrollEnabled = true - /// Amount of overscroll to add in the vertical direction. - /// - /// The overscroll is a factor of the scrollable area height and will not take into account any insets. 0 means no overscroll and 1 means an amount equal to the height of the text view. Detaults to 0. - public var verticalOverscrollFactor: CGFloat = 0 { - didSet { - if horizontalOverscrollFactor != oldValue { - hasPendingContentSizeUpdate = true - handleContentSizeUpdateIfNeeded() - } - } - } - /// Amount of overscroll to add in the horizontal direction. - /// - /// The overscroll is a factor of the scrollable area height and will not take into account any insets or the width of the gutter. 0 means no overscroll and 1 means an amount equal to the width of the text view. Detaults to 0. - public var horizontalOverscrollFactor: CGFloat = 0 { - didSet { - if horizontalOverscrollFactor != oldValue { - hasPendingContentSizeUpdate = true - handleContentSizeUpdateIfNeeded() - } - } - } - /// 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? { - return 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 { - return textInputView.highlightedRanges - } - set { - textInputView.highlightedRanges = newValue - highlightNavigationController.highlightedRanges = newValue - } - } - /// Wheter the text view should loop when navigating through highlighted ranges using `selectPreviousHighlightedRange` or `selectNextHighlightedRange` on the text view. - public var highlightedRangeLoopingMode: HighlightedRangeLoopingMode { - get { - if highlightNavigationController.loopRanges { - return .enabled - } else { - return .disabled - } - } - set { - switch newValue { - case .enabled: - highlightNavigationController.loopRanges = true - case .disabled: - highlightNavigationController.loopRanges = false - } - } - } - /// Line endings to use when inserting a line break. - /// - /// The value only affects new line breaks inserted in the text view and changing this value does not change the line endings of the text in the text view. Defaults to Unix (LF). - /// - /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)`` and ``TextView/replaceText(in:)``. - public var lineEndings: LineEnding { - get { - return textInputView.lineEndings - } - set { - textInputView.lineEndings = newValue - } - } -#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. - @available(iOS 16, *) - public var isFindInteractionEnabled: Bool { - get { - return textSearchingHelper.isFindInteractionEnabled - } - set { - textSearchingHelper.isFindInteractionEnabled = newValue - } - } - /// The text view’s built-in find interaction. - /// - /// Set to true to enable the text view's built-in find interaction. This method returns nil when the interaction isn't enabled. - /// - /// Call [`presentFindNavigator(showingReplace:)`](https://developer.apple.com/documentation/uikit/uifindinteraction/3975832-presentfindnavigator) on the UIFindInteraction object to invoke the find interaction and display the find panel. - @available(iOS 16, *) - public var findInteraction: UIFindInteraction? { - return 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? { - return _editMenuInteraction as? UIEditMenuInteraction - } - private var _editMenuInteraction: Any? -#endif - private let tapGestureRecognizer = QuickTapGestureRecognizer() - private var _inputAccessoryView: UIView? - private let _inputAssistantItem = UITextInputAssistantItem() - private var isPerformingNonEditableTextInteraction = false - private var delegateAllowsEditingToBegin: Bool { - guard isEditable else { - return false - } - if let editorDelegate = editorDelegate { - return editorDelegate.textViewShouldBeginEditing(self) - } else { - return true - } - } - private var shouldEndEditing: Bool { - if let editorDelegate = editorDelegate { - return editorDelegate.textViewShouldEndEditing(self) - } else { - return true - } - } - private var hasPendingContentSizeUpdate = false - private var isInputAccessoryViewEnabled = false - private let keyboardObserver = KeyboardObserver() - private let highlightNavigationController = HighlightNavigationController() - private var textSearchingHelper = UITextSearchingHelper() - // Store a reference to instances of the private type UITextRangeAdjustmentGestureRecognizer in order to track adjustments - // to the selected text range and scroll the text view when the handles approach the bottom. - // The approach is based on the one described in Steve Shephard's blog post "Adventures with UITextInteraction". - // https://steveshepard.com/blog/adventures-with-uitextinteraction/ - private var textRangeAdjustmentGestureRecognizers: Set = [] - private var previousSelectedRangeDuringGestureHandling: NSRange? - private var preferredContentSize: CGSize { - let horizontalOverscrollLength = max(frame.width * horizontalOverscrollFactor, 0) - let verticalOverscrollLength = max(frame.height * verticalOverscrollFactor, 0) - let baseContentSize = textInputView.contentSize - let width = isLineWrappingEnabled ? baseContentSize.width : baseContentSize.width + horizontalOverscrollLength - let height = baseContentSize.height + verticalOverscrollLength - return CGSize(width: width, height: height) - } - - /// Create a new text view. - /// - Parameter frame: The frame rectangle of the text view. - override public init(frame: CGRect) { - textInputView = TextInputView(theme: DefaultTheme()) - super.init(frame: frame) - backgroundColor = .white - textInputView.delegate = self - textInputView.gutterParentView = self - editableTextInteraction.textInput = textInputView - nonEditableTextInteraction.textInput = textInputView - editableTextInteraction.delegate = self - nonEditableTextInteraction.delegate = self - addSubview(textInputView) - tapGestureRecognizer.delegate = self - tapGestureRecognizer.addTarget(self, action: #selector(handleTap(_:))) - addGestureRecognizer(tapGestureRecognizer) - installNonEditableInteraction() - keyboardObserver.delegate = self - highlightNavigationController.delegate = self - textSearchingHelper.textView = self - } - - /// The initializer has not been implemented. - /// - Parameter coder: Not used. - public required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - /// Lays out subviews. - override open func layoutSubviews() { - super.layoutSubviews() - handleContentSizeUpdateIfNeeded() - textInputView.scrollViewWidth = frame.width - textInputView.frame = CGRect(x: 0, y: 0, width: max(contentSize.width, frame.width), height: max(contentSize.height, frame.height)) - textInputView.viewport = CGRect(origin: contentOffset, size: frame.size) - bringSubviewToFront(textInputView.gutterContainerView) - } - - /// Called when the safe area of the view changes. - override open func safeAreaInsetsDidChange() { - super.safeAreaInsetsDidChange() - textInputView.scrollViewSafeAreaInsets = safeAreaInsets - contentSize = preferredContentSize - layoutIfNeeded() - } - - /// Asks UIKit to make this object the first responder in its window. - @discardableResult - override open func becomeFirstResponder() -> Bool { - if !isEditing && delegateAllowsEditingToBegin { - _ = textInputView.resignFirstResponder() - _ = textInputView.becomeFirstResponder() - return true - } else { - return false - } - } - - /// Notifies this object that it has been asked to relinquish its status as first responder in its window. - @discardableResult - override open func resignFirstResponder() -> Bool { - if isEditing && shouldEndEditing { - return textInputView.resignFirstResponder() - } else { - return false - } - } - - /// Updates the custom input and accessory views when the object is the first responder. - override open func reloadInputViews() { - textInputView.reloadInputViews() - } - - /// Sets the current _state_ of the editor. The state contains the text to be displayed by the editor and - /// various additional information about the text that the editor needs to show the text. - /// - /// It is safe to create an instance of TextViewState in the background, and as such it can be - /// created before presenting the editor to the user, e.g. when opening the document from an instance of - /// UIDocumentBrowserViewController. - /// - /// This is the preferred way to initially set the text, language and theme on the TextView. - /// - Parameter state: The new state to be used by the editor. - /// - Parameter addUndoAction: Whether the state change can be undone. Defaults to false. - public func setState(_ state: TextViewState, addUndoAction: Bool = false) { - textInputView.setState(state, addUndoAction: addUndoAction) - contentSize = preferredContentSize - } - - /// Returns the row and column at the specified location in the text. - /// Common usages of this includes showing the line and column that the caret is currently located at. - /// - Parameter location: The location is relative to the first index in the string. - /// - Returns: The text location if the input location could be found in the string, otherwise nil. - public func textLocation(at location: Int) -> TextLocation? { - if let linePosition = textInputView.linePosition(at: location) { - return TextLocation(linePosition) - } else { - return nil - } - } - - /// Returns the character location at the specified row and column. - /// - Parameter textLocation: The row and column in the text. - /// - Returns: The location if the input row and column could be found in the text, otherwise nil. - public func location(at textLocation: TextLocation) -> Int? { - let lineIndex = textLocation.lineNumber - guard lineIndex >= 0 && lineIndex < textInputView.lineManager.lineCount else { - return nil - } - let line = textInputView.lineManager.line(atRow: lineIndex) - guard textLocation.column >= 0 && textLocation.column <= line.data.totalLength else { - return nil - } - return line.location + textLocation.column - } - - /// Sets the language mode on a background thread. - /// - /// - Parameters: - /// - languageMode: The new language mode to be used by the editor. - /// - completion: Called when the content have been parsed or when parsing fails. - public func setLanguageMode(_ languageMode: LanguageMode, completion: ((Bool) -> Void)? = nil) { - textInputView.setLanguageMode(languageMode, completion: completion) - } - - /// Inserts text at the location of the caret or, if no selection or caret is present, at the end of the text. - /// - Parameter text: A string to insert. - open func insertText(_ text: String) { - textInputView.inputDelegate?.selectionWillChange(textInputView) - textInputView.insertText(text) - textInputView.inputDelegate?.selectionDidChange(textInputView) - } - - /// Replaces the text that is in the specified range. - /// - Parameters: - /// - range: A range of text in the document. - /// - text: A string to replace the text in range. - open func replace(_ range: UITextRange, withText text: String) { - textInputView.inputDelegate?.selectionWillChange(textInputView) - textInputView.replace(range, withText: text) - textInputView.inputDelegate?.selectionDidChange(textInputView) - } - - /// Replaces the text that is in the specified range. - /// - Parameters: - /// - range: A range of text in the document. - /// - text: A string to replace the text in range. - public func replace(_ range: NSRange, withText text: String) { - textInputView.inputDelegate?.selectionWillChange(textInputView) - let indexedRange = IndexedRange(range) - textInputView.replace(indexedRange, withText: text) - textInputView.inputDelegate?.selectionDidChange(textInputView) - } - - /// Replaces the text in the specified matches. - /// - Parameters: - /// - batchReplaceSet: Set of ranges to replace with a text. - public func replaceText(in batchReplaceSet: BatchReplaceSet) { - textInputView.replaceText(in: batchReplaceSet) - } - - /// Deletes a character from the displayed text. - public func deleteBackward() { - textInputView.deleteBackward() - } - - /// Returns the text in the specified range. - /// - Parameter range: A range of text in the document. - /// - Returns: The substring that falls within the specified range. - public func text(in range: NSRange) -> String? { - return textInputView.text(in: range) - } - - /// Returns the syntax node at the specified location in the document. - /// - /// This can be used with character pairs to determine if a pair should be inserted or not. - /// For example, a character pair consisting of two quotes (") to surround a string, should probably not be - /// inserted when the quote is typed while the caret is already inside a string. - /// - /// This requires a language to be set on the editor. - /// - Parameter location: A location in the document. - /// - Returns: The syntax node at the location. - public func syntaxNode(at location: Int) -> SyntaxNode? { - return textInputView.syntaxNode(at: location) - } - - /// Checks if the specified locations is within the indentation of the line. - /// - /// - Parameter location: A location in the document. - /// - Returns: True if the location is within the indentation of the line, otherwise false. - public func isIndentation(at location: Int) -> Bool { - return textInputView.isIndentation(at: location) - } - - /// Decreases the indentation level of the selected lines. - public func shiftLeft() { - textInputView.shiftLeft() - } - - /// Increases the indentation level of the selected lines. - public func shiftRight() { - textInputView.shiftRight() - } - - /// Moves the selected lines up by one line. - /// - /// Calling this function has no effect when the selected lines include the first line in the text view. - public func moveSelectedLinesUp() { - textInputView.moveSelectedLinesUp() - } - - /// Moves the selected lines down by one line. - /// - /// Calling this function has no effect when the selected lines include the last line in the text view. - public func moveSelectedLinesDown() { - textInputView.moveSelectedLinesDown() - } - - /// Attempts to detect the indent strategy used in the document. This may return an unknown strategy even - /// when the document contains indentation. - public func detectIndentStrategy() -> DetectedIndentStrategy { - return textInputView.detectIndentStrategy() - } - - /// Go to the beginning of the line at the specified index. - /// - /// - Parameter lineIndex: Index of line to navigate to. - /// - Parameter selection: The placement of the caret on the line. - /// - Returns: True if the text view could navigate to the specified line index, otherwise false. - @discardableResult - public func goToLine(_ lineIndex: Int, select selection: GoToLineSelection = .beginning) -> Bool { - guard lineIndex >= 0 && lineIndex < textInputView.lineManager.lineCount else { - return false - } - // I'm not exactly sure why this is necessary but if the text view is the first responder as we jump - // to the line and we don't resign the first responder first, the caret will disappear after we have - // jumped to the specified line. - resignFirstResponder() - becomeFirstResponder() - let line = textInputView.lineManager.line(atRow: lineIndex) - textInputView.layoutLines(toLocation: line.location) - scrollLocationToVisible(line.location) - layoutIfNeeded() - switch selection { - case .beginning: - textInputView.selectedRange = NSRange(location: line.location, length: 0) - case .end: - textInputView.selectedRange = NSRange(location: line.data.length, length: line.data.length) - case .line: - textInputView.selectedRange = NSRange(location: line.location, length: line.data.length) - } - return true - } - - /// Search for the specified query. - /// - /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:)``. - /// - /// ```swift - /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) - /// let results = textView.search(for: query) - /// ``` - /// - /// - Parameter query: Query to find matches for. - /// - Returns: Results matching the query. - public func search(for query: SearchQuery) -> [SearchResult] { - let searchController = SearchController(stringView: textInputView.stringView) - searchController.delegate = self - return searchController.search(for: query) - } - - /// Search for the specified query and return results that take a replacement string into account. - /// - /// When searching for a regular expression this function will perform pattern matching and take the matched groups into account in the returned results. - /// - /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:replacingMatchesWith:)`` and how the returned search results can be used to perform a replace operation. - /// - /// ```swift - /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) - /// let results = textView.search(for: query, replacingMatchesWith: "bar") - /// let replacements = results.map { BatchReplaceSet.Replacement(range: $0.range, text: $0.replacementText) } - /// let batchReplaceSet = BatchReplaceSet(replacements: replacements) - /// textView.replaceText(in: batchReplaceSet) - /// ``` - /// - /// - Parameters: - /// - query: Query to find matches for. - /// - replacementString: String to replace matches with. Can refer to groups in a regular expression using $0, $1, $2 etc. - /// - Returns: Results matching the query. - public func search(for query: SearchQuery, replacingMatchesWith replacementString: String) -> [SearchReplaceResult] { - let searchController = SearchController(stringView: textInputView.stringView) - searchController.delegate = self - return searchController.search(for: query, replacingMatchesWith: replacementString) - } - - /// Returns a peek into the text view's underlying attributed string. - /// - Parameter range: Range of text to include in text view. The returned result may span a larger range than the one specified. - /// - Returns: Text preview containing the specified range. - public func textPreview(containing range: NSRange) -> TextPreview? { - return textInputView.textPreview(containing: range) - } - - /// Selects a highlighted range behind the selected range if possible. - public func selectPreviousHighlightedRange() { - highlightNavigationController.selectPreviousRange() - } - - /// Selects a highlighted range after the selected range if possible. - public func selectNextHighlightedRange() { - highlightNavigationController.selectNextRange() - } - - /// Selects the highlighed range at the specified index. - /// - Parameter index: Index of highlighted range to select. - public func selectHighlightedRange(at index: Int) { - highlightNavigationController.selectRange(at: index) - } - - /// Synchronously displays the visible lines. This can be used to immediately update the visible lines after setting the theme. Use with caution as this redisplaying the visible lines can be a costly operation. - public func redisplayVisibleLines() { - textInputView.redisplayVisibleLines() - } -} - -// MARK: - UITextInput -extension TextView { - /// The range of currently marked text in a document. - public var markedTextRange: UITextRange? { - return textInputView.markedTextRange - } - - /// The text position for the beginning of a document. - public var beginningOfDocument: UITextPosition { - return textInputView.beginningOfDocument - } - - /// The text position for the end of a document. - public var endOfDocument: UITextPosition { - return textInputView.endOfDocument - } - - /// Returns the range between two text positions. - /// - Parameters: - /// - fromPosition: An object that represents a location in a document. - /// - toPosition: An object that represents another location in a document. - /// - Returns: An object that represents the range between fromPosition and toPosition. - public func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { - return textInputView.textRange(from: fromPosition, to: toPosition) - } - - /// Returns the text position at a specified offset from another text position. - /// - Parameters: - /// - position: A custom UITextPosition object that represents a location in a document. - /// - offset: A character offset from position. It can be a positive or negative value. - /// - Returns: A custom UITextPosition object that represents the location in a document that is at the specified offset from position. Returns nil if the computed text position is less than 0 or greater than the length of the backing string. - public func position(from position: UITextPosition, offset: Int) -> UITextPosition? { - return textInputView.position(from: position, offset: offset) - } - - /// Returns the text position at a specified offset in a specified direction from another text position. - /// - Parameters: - /// - position: A custom UITextPosition object that represents a location in a document. - /// - direction: A UITextLayoutDirection constant that represents the direction of the offset from position. - /// - offset: A character offset from position. - /// - Returns: Returns the text position at a specified offset in a specified direction from another text position. Returns nil if the computed text position is less than 0 or greater than the length of the backing string. - public func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? { - return textInputView.position(from: position, in: direction, offset: offset) - } - - /// Returns how one text position compares to another text position. - /// - Parameters: - /// - position: A custom object that represents a location within a document. - /// - other: A custom object that represents another location within a document. - /// - Returns: A value that indicates whether the two text positions are identical or whether one is before the other. - public func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { - return textInputView.compare(position, to: other) - } - - /// Returns the number of UTF-16 characters between one text position and another text position. - /// - Parameters: - /// - from: A custom object that represents a location within a document. - /// - toPosition: A custom object that represents another location within document. - /// - Returns: The number of UTF-16 characters between fromPosition and toPosition. - public func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { - return textInputView.offset(from: from, to: toPosition) - } - - /// An input tokenizer that provides information about the granularity of text units. - public var tokenizer: UITextInputTokenizer { - return textInputView.tokenizer - } - - /// Returns the text position that is at the farthest extent in a specified layout direction within a range of text. - /// - Parameters: - /// - range: A text-range object that demarcates a range of text in a document. - /// - direction: A constant that indicates a direction of layout (right, left, up, down). - /// - Returns: A text-position object that identifies a location in the visible text. - public func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { - return textInputView.position(within: range, farthestIn: direction) - } - - /// Returns a text range from a specified text position to its farthest extent in a certain direction of layout. - /// - Parameters: - /// - position: A text-position object that identifies a location in a document. - /// - direction: A constant that indicates a direction of layout (right, left, up, down). - /// - Returns: A text-range object that represents the distance from position to the farthest extent in direction. - public func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { - return textInputView.characterRange(byExtending: position, in: direction) - } - - /// Returns the first rectangle that encloses a range of text in a document. - /// - Parameter range: An object that represents a range of text in a document. - /// - Returns: The first rectangle in a range of text. You might use this rectangle to draw a correction rectangle. The “first” in the name refers the rectangle enclosing the first line when the range encompasses multiple lines of text. - public func firstRect(for range: UITextRange) -> CGRect { - return textInputView.firstRect(for: range) - } - - /// Returns a rectangle to draw the caret at a specified insertion point. - /// - Parameter position: An object that identifies a location in a text input area. - /// - Returns: A rectangle that defines the area for drawing the caret. - public func caretRect(for position: UITextPosition) -> CGRect { - return textInputView.caretRect(for: position) - } - - /// Returns an array of selection rects corresponding to the range of text. - /// - Parameter range: An object representing a range in a document’s text. - /// - Returns: An array of UITextSelectionRect objects that encompass the selection. - public func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { - return textInputView.selectionRects(for: range) - } - - /// Returns the position in a document that is closest to a specified point. - /// - Parameter point: A point in the view that is drawing a document’s text. - /// - Returns: An object locating a position in a document that is closest to point. - public func closestPosition(to point: CGPoint) -> UITextPosition? { - return textInputView.closestPosition(to: point) - } - - /// Returns the position in a document that is closest to a specified point in a specified range. - /// - Parameters: - /// - point: A point in the view that is drawing a document’s text. - /// - range: An object representing a range in a document’s text. - /// - Returns: An object representing the character position in range that is closest to point. - public func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { - return textInputView.closestPosition(to: point, within: range) - } - - /// Returns the character or range of characters that is at a specified point in a document. - /// - Parameter point: A point in the view that is drawing a document’s text. - /// - Returns: An object representing a range that encloses a character (or characters) at point. - public func characterRange(at point: CGPoint) -> UITextRange? { - return textInputView.characterRange(at: point) - } - - /// Returns the text in the specified range. - /// - Parameter range: A range of text in a document. - /// - Returns: A substring of a document that falls within the specified range. - public func text(in range: UITextRange) -> String? { - return textInputView.text(in: range) - } - - /// A Boolean value that indicates whether the text-entry object has any text. - public var hasText: Bool { - return textInputView.hasText - } - - /// Scrolls the text view to reveal the text in the specified range. - /// - /// The function will scroll the text view as little as possible while revealing as much as possible of the specified range. It is not guaranteed that the entire range is visible after performing the scroll. - /// - /// - Parameters: - /// - range: The range of text to scroll into view. - public func scrollRangeToVisible(_ range: NSRange) { - textInputView.layoutLines(toLocation: range.upperBound) - justScrollRangeToVisible(range) - } -} - -private extension TextView { - @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard isSelectable else { - return - } - if gestureRecognizer.state == .ended { - let point = gestureRecognizer.location(in: textInputView) - let oldSelectedRange = textInputView.selectedRange - textInputView.moveCaret(to: point) - if textInputView.selectedRange != oldSelectedRange { - layoutIfNeeded() - } - installEditableInteraction() - becomeFirstResponder() - } - } - - @objc private func handleTextRangeAdjustmentPan(_ gestureRecognizer: UIPanGestureRecognizer) { - // This function scroll the text view when the selected range is adjusted. - if gestureRecognizer.state == .began { - previousSelectedRangeDuringGestureHandling = selectedRange - } else if gestureRecognizer.state == .changed, let previousSelectedRange = previousSelectedRangeDuringGestureHandling { - if selectedRange.lowerBound != previousSelectedRange.lowerBound { - // User is adjusting the lower bound (location) of the selected range. - scrollLocationToVisible(selectedRange.lowerBound) - } else if selectedRange.upperBound != previousSelectedRange.upperBound { - // User is adjusting the upper bound (length) of the selected range. - scrollLocationToVisible(selectedRange.upperBound) - } - previousSelectedRangeDuringGestureHandling = selectedRange - } - } - - private func insertLeadingComponent(of characterPair: CharacterPair, in range: NSRange) -> Bool { - let shouldInsertCharacterPair = editorDelegate?.textView(self, shouldInsert: characterPair, in: range) ?? true - guard shouldInsertCharacterPair else { - return false - } - guard let selectedRange = textInputView.selectedRange else { - return false - } - if selectedRange.length == 0 { - textInputView.insertText(characterPair.leading + characterPair.trailing) - textInputView.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: 0) - return true - } else if let text = textInputView.text(in: selectedRange) { - let modifiedText = characterPair.leading + text + characterPair.trailing - let indexedRange = IndexedRange(selectedRange) - textInputView.replace(indexedRange, withText: modifiedText) - textInputView.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: range.length) - return true - } else { - return false - } - } - - private func skipInsertingTrailingComponent(of characterPair: CharacterPair, in range: NSRange) -> Bool { - // When typing the trailing component of a character pair, e.g. ) or } and the cursor is just in front of that character, - // the delegate is asked whether the text view should skip inserting that character. If the character is skipped, - // then the caret is moved after the trailing character component. - let followingTextRange = NSRange(location: range.location + range.length, length: characterPair.trailing.count) - let followingText = textInputView.text(in: followingTextRange) - guard followingText == characterPair.trailing else { - return false - } - let shouldSkip = editorDelegate?.textView(self, shouldSkipTrailingComponentOf: characterPair, in: range) ?? true - if shouldSkip { - moveCaret(byOffset: characterPair.trailing.count) - return true - } else { - return false - } - } - - private func moveCaret(byOffset offset: Int) { - if let selectedRange = textInputView.selectedRange { - textInputView.selectedRange = NSRange(location: selectedRange.location + offset, length: 0) - } - } - - private func handleContentSizeUpdateIfNeeded() { - if hasPendingContentSizeUpdate { - // We don't want to update the content size when the scroll view is "bouncing" near the gutter, - // or at the end of a line since it causes flickering when updating the content size while scrolling. - // However, we do allow updating the content size if the text view is scrolled far enough on - // the y-axis as that means it will soon run out of text to display. - let isBouncingAtGutter = contentOffset.x < -contentInset.left - let isBouncingAtLineEnd = contentOffset.x > contentSize.width - frame.size.width + contentInset.right - let isBouncingHorizontally = isBouncingAtGutter || isBouncingAtLineEnd - let isCriticalUpdate = contentOffset.y > contentSize.height - frame.height * 1.5 - let isScrolling = isDragging || isDecelerating - if !isBouncingHorizontally || isCriticalUpdate || !isScrolling { - hasPendingContentSizeUpdate = false - let oldContentOffset = contentOffset - contentSize = preferredContentSize - contentOffset = oldContentOffset - setNeedsLayout() - } - } - } - - private func justScrollRangeToVisible(_ range: NSRange) { - let lowerBoundRect = textInputView.caretRect(at: range.lowerBound) - let upperBoundRect = range.length == 0 ? lowerBoundRect : textInputView.caretRect(at: range.upperBound) - let rectMinX = min(lowerBoundRect.minX, upperBoundRect.minX) - let rectMaxX = max(lowerBoundRect.maxX, upperBoundRect.maxX) - let rectMinY = min(lowerBoundRect.minY, upperBoundRect.minY) - let rectMaxY = max(lowerBoundRect.maxY, upperBoundRect.maxY) - let rect = CGRect(x: rectMinX, y: rectMinY, width: rectMaxX - rectMinX, height: rectMaxY - rectMinY) - contentOffset = contentOffsetForScrollingToVisibleRect(rect) - } - - private func scrollLocationToVisible(_ location: Int) { - let range = NSRange(location: location, length: 0) - justScrollRangeToVisible(range) - } - - private func installEditableInteraction() { - if editableTextInteraction.view == nil { - isInputAccessoryViewEnabled = true - textInputView.removeInteraction(nonEditableTextInteraction) - textInputView.addInteraction(editableTextInteraction) - } - } - - private func installNonEditableInteraction() { - if nonEditableTextInteraction.view == nil { - isInputAccessoryViewEnabled = false - textInputView.removeInteraction(editableTextInteraction) - textInputView.addInteraction(nonEditableTextInteraction) - for gestureRecognizer in nonEditableTextInteraction.gesturesForFailureRequirements { - gestureRecognizer.require(toFail: tapGestureRecognizer) - } - } - } - - /// Computes a content offset to scroll to in order to reveal the specified rectangle. - /// - /// The function will return a rectangle that scrolls the text view a minimum amount while revealing as much as possible of the rectangle. It is not guaranteed that the entire rectangle can be revealed. - /// - Parameter rect: The rectangle to reveal. - /// - Returns: The content offset to scroll to. - private func contentOffsetForScrollingToVisibleRect(_ rect: CGRect) -> CGPoint { - // Create the viewport: a rectangle containing the content that is visible to the user. - var viewport = CGRect(x: contentOffset.x, y: contentOffset.y, width: frame.width, height: frame.height) - viewport.origin.y += adjustedContentInset.top - viewport.origin.x += adjustedContentInset.left + gutterWidth - viewport.size.width -= adjustedContentInset.left + adjustedContentInset.right + gutterWidth - viewport.size.height -= adjustedContentInset.top + adjustedContentInset.bottom - // Construct the best possible content offset. - var newContentOffset = contentOffset - if rect.minX < viewport.minX { - newContentOffset.x -= viewport.minX - rect.minX - } else if rect.maxX > viewport.maxX && rect.width <= viewport.width { - // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. - newContentOffset.x += rect.maxX - viewport.maxX - } else if rect.maxX > viewport.maxX { - newContentOffset.x += rect.minX - } - if rect.minY < viewport.minY { - newContentOffset.y -= viewport.minY - rect.minY - } else if rect.maxY > viewport.maxY && rect.height <= viewport.height { - // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. - newContentOffset.y += rect.maxY - viewport.maxY - } else if rect.maxY > viewport.maxY { - newContentOffset.y += rect.minY - } - let cappedXOffset = min(max(newContentOffset.x, minimumContentOffset.x), maximumContentOffset.x) - let cappedYOffset = min(max(newContentOffset.y, minimumContentOffset.y), maximumContentOffset.y) - return CGPoint(x: cappedXOffset, y: cappedYOffset) - } -} - -// MARK: - TextInputViewDelegate -extension TextView: TextInputViewDelegate { - func textInputViewWillBeginEditing(_ view: TextInputView) { - guard isEditable else { - return - } - isEditing = !isPerformingNonEditableTextInteraction - // If a developer is programmatically calling becomeFirstresponder() then we might not have a selected range. - // We set the selectedRange instead of the selectedTextRange to avoid invoking any delegates. - if textInputView.selectedRange == nil && !isPerformingNonEditableTextInteraction { - textInputView.selectedRange = NSRange(location: 0, length: 0) - } - // Ensure selection is laid out without animation. - UIView.performWithoutAnimation { - textInputView.layoutIfNeeded() - } - // The editable interaction must be installed early in the -becomeFirstResponder() call - if !isPerformingNonEditableTextInteraction { - installEditableInteraction() - } - } - - func textInputViewDidBeginEditing(_ view: TextInputView) { - if !isPerformingNonEditableTextInteraction { - editorDelegate?.textViewDidBeginEditing(self) - } - } - - func textInputViewDidCancelBeginEditing(_ view: TextInputView) { - isEditing = false - installNonEditableInteraction() - } - - func textInputViewDidEndEditing(_ view: TextInputView) { - isEditing = false - installNonEditableInteraction() - editorDelegate?.textViewDidEndEditing(self) - } - - func textInputViewDidChange(_ view: TextInputView) { - if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { - scrollLocationToVisible(newRange.location) - } - editorDelegate?.textViewDidChange(self) - } - - func textInputViewDidChangeSelection(_ view: TextInputView) { - UIMenuController.shared.hideMenu(from: self) - highlightNavigationController.selectedRange = view.selectedRange - if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { - scrollLocationToVisible(newRange.location) - } - editorDelegate?.textViewDidChangeSelection(self) - } - - func textInputViewDidInvalidateContentSize(_ view: TextInputView) { - if contentSize != view.contentSize { - hasPendingContentSizeUpdate = true - handleContentSizeUpdateIfNeeded() - } - } - - func textInputView(_ view: TextInputView, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) { - let isScrolling = isDragging || isDecelerating - if contentOffsetAdjustment != .zero && isScrolling { - contentOffset = CGPoint(x: contentOffset.x + contentOffsetAdjustment.x, y: contentOffset.y + contentOffsetAdjustment.y) - } - } - - func textInputView(_ view: TextInputView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - if textInputView.isRestoringPreviouslyDeletedText { - // UIKit is inserting text to combine characters, for example to combine two Korean characters into one, and we do not want to interfere with that. - return editorDelegate?.textView(self, shouldChangeTextIn: range, replacementText: text) ?? true - } else if let characterPair = characterPairs.first(where: { $0.trailing == text }), - skipInsertingTrailingComponent(of: characterPair, in: range) { - return false - } else if let characterPair = characterPairs.first(where: { $0.leading == text }), insertLeadingComponent(of: characterPair, in: range) { - return false - } else { - return editorDelegate?.textView(self, shouldChangeTextIn: range, replacementText: text) ?? true - } - } - - func textInputViewDidChangeGutterWidth(_ view: TextInputView) { - editorDelegate?.textViewDidChangeGutterWidth(self) - } - - func textInputViewDidBeginFloatingCursor(_ view: TextInputView) { - editorDelegate?.textViewDidBeginFloatingCursor(self) - } - - func textInputViewDidEndFloatingCursor(_ view: TextInputView) { - editorDelegate?.textViewDidEndFloatingCursor(self) - } - - func textInputViewDidUpdateMarkedRange(_ view: TextInputView) { - // There seems to be a bug in UITextInput (or UITextInteraction?) where updating the markedTextRange of a UITextInput - // will cause the caret to disappear. Removing the editable text interaction and adding it back will work around this issue. - DispatchQueue.main.async { - if !view.viewHierarchyContainsCaret && self.editableTextInteraction.view != nil { - view.removeInteraction(self.editableTextInteraction) - view.addInteraction(self.editableTextInteraction) - } - } - } - - func textInputView(_ view: TextInputView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - return editorDelegate?.textView(self, canReplaceTextIn: highlightedRange) ?? false - } - - func textInputView(_ view: TextInputView, replaceTextIn highlightedRange: HighlightedRange) { - editorDelegate?.textView(self, replaceTextIn: highlightedRange) - } -} - -// MARK: - HighlightNavigationControllerDelegate -extension TextView: HighlightNavigationControllerDelegate { - func highlightNavigationController(_ controller: HighlightNavigationController, - shouldNavigateTo highlightNavigationRange: HighlightNavigationRange) { - let range = highlightNavigationRange.range - scrollRangeToVisible(range) - textInputView.selectedTextRange = IndexedRange(range) - _ = textInputView.becomeFirstResponder() - textInputView.presentEditMenuForText(in: range) - switch highlightNavigationRange.loopMode { - case .previousGoesToLast: - editorDelegate?.textViewDidLoopToLastHighlightedRange(self) - case .nextGoesToFirst: - editorDelegate?.textViewDidLoopToFirstHighlightedRange(self) - case .disabled: - break - } - } -} - -// MARK: - SearchControllerDelegate -extension TextView: SearchControllerDelegate { - func searchController(_ searchController: SearchController, linePositionAt location: Int) -> LinePosition? { - return textInputView.lineManager.linePosition(at: location) - } -} - -// MARK: - UIGestureRecognizerDelegate -extension TextView: UIGestureRecognizerDelegate { - override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer === tapGestureRecognizer { - return !isEditing && !isDragging && !isDecelerating && delegateAllowsEditingToBegin - } else { - return super.gestureRecognizerShouldBegin(gestureRecognizer) - } - } - - public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - if let klass = NSClassFromString("UITextRangeAdjustmentGestureRecognizer") { - if !textRangeAdjustmentGestureRecognizers.contains(otherGestureRecognizer) && otherGestureRecognizer.isKind(of: klass) { - otherGestureRecognizer.addTarget(self, action: #selector(handleTextRangeAdjustmentPan(_:))) - textRangeAdjustmentGestureRecognizers.insert(otherGestureRecognizer) - } - } - return gestureRecognizer !== panGestureRecognizer - } -} - -// MARK: - KeyboardObserverDelegate -extension TextView: KeyboardObserverDelegate { - func keyboardObserver(_ keyboardObserver: KeyboardObserver, - keyboardWillShowWithHeight keyboardHeight: CGFloat, - animation: KeyboardObserver.Animation?) { - if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { - scrollRangeToVisible(newRange) - } - } -} - -// MARK: - UITextInteractionDelegate -extension TextView: UITextInteractionDelegate { - public func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { - if interaction.textInteractionMode == .editable { - return isEditable - } else if interaction.textInteractionMode == .nonEditable { - // The private UITextLoupeInteraction and UITextNonEditableInteractionclass will end up in this case. The latter is likely created from UITextInteraction(for: .nonEditable) but we want to disable both when selection is disabled. - return isSelectable - } else { - return true - } - } - - public func interactionWillBegin(_ interaction: UITextInteraction) { - if interaction.textInteractionMode == .nonEditable { - // When long-pressing our instance of UITextInput, the UITextInteraction will make the text input first responder. - // In this case the user wants to select text in the text view but not start editing, so we set a flag that tells us - // that we should not install editable text interaction in this case. - isPerformingNonEditableTextInteraction = true - } - } - - public func interactionDidEnd(_ interaction: UITextInteraction) { - if interaction.textInteractionMode == .nonEditable { - isPerformingNonEditableTextInteraction = false - } - } -} diff --git a/Sources/Runestone/TextView/Core/EditMenuController.swift b/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift similarity index 99% rename from Sources/Runestone/TextView/Core/EditMenuController.swift rename to Sources/Runestone/TextView/Core/iOS/EditMenuController.swift index 6253bfc47..fc5bd3478 100644 --- a/Sources/Runestone/TextView/Core/EditMenuController.swift +++ b/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift @@ -1,3 +1,4 @@ +#if os(iOS) import UIKit protocol EditMenuControllerDelegate: AnyObject { @@ -114,3 +115,4 @@ extension EditMenuController: UIEditMenuInteractionDelegate { } } #endif +#endif diff --git a/Sources/Runestone/TextView/Core/FloatingCaretView.swift b/Sources/Runestone/TextView/Core/iOS/FloatingCaretView.swift similarity index 90% rename from Sources/Runestone/TextView/Core/FloatingCaretView.swift rename to Sources/Runestone/TextView/Core/iOS/FloatingCaretView.swift index e6c56889e..bf5fadec5 100644 --- a/Sources/Runestone/TextView/Core/FloatingCaretView.swift +++ b/Sources/Runestone/TextView/Core/iOS/FloatingCaretView.swift @@ -1,3 +1,4 @@ +#if os(iOS) import UIKit final class FloatingCaretView: UIView { @@ -6,3 +7,4 @@ final class FloatingCaretView: UIView { layer.cornerRadius = floor(bounds.width / 2) } } +#endif diff --git a/Sources/Runestone/TextView/Core/IndexedPosition.swift b/Sources/Runestone/TextView/Core/iOS/IndexedPosition.swift similarity index 87% rename from Sources/Runestone/TextView/Core/IndexedPosition.swift rename to Sources/Runestone/TextView/Core/iOS/IndexedPosition.swift index b42c0198a..04716f8e1 100644 --- a/Sources/Runestone/TextView/Core/IndexedPosition.swift +++ b/Sources/Runestone/TextView/Core/iOS/IndexedPosition.swift @@ -1,3 +1,4 @@ +#if os(iOS) import UIKit final class IndexedPosition: UITextPosition { @@ -7,3 +8,4 @@ final class IndexedPosition: UITextPosition { self.index = index } } +#endif diff --git a/Sources/Runestone/TextView/Core/IndexedRange.swift b/Sources/Runestone/TextView/Core/iOS/IndexedRange.swift similarity index 96% rename from Sources/Runestone/TextView/Core/IndexedRange.swift rename to Sources/Runestone/TextView/Core/iOS/IndexedRange.swift index 15fd13fd4..07b772935 100644 --- a/Sources/Runestone/TextView/Core/IndexedRange.swift +++ b/Sources/Runestone/TextView/Core/iOS/IndexedRange.swift @@ -1,3 +1,4 @@ +#if os(iOS) import UIKit final class IndexedRange: UITextRange { @@ -21,3 +22,4 @@ final class IndexedRange: UITextRange { self.init(range) } } +#endif diff --git a/Sources/Runestone/TextView/Core/LineMovementController.swift b/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift similarity index 99% rename from Sources/Runestone/TextView/Core/LineMovementController.swift rename to Sources/Runestone/TextView/Core/iOS/LineMovementController.swift index 60638eba5..9077942ab 100644 --- a/Sources/Runestone/TextView/Core/LineMovementController.swift +++ b/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift @@ -1,3 +1,4 @@ +#if os(iOS) import UIKit final class LineMovementController { @@ -136,3 +137,4 @@ private extension LineMovementController { return lineController.numberOfLineFragments } } +#endif diff --git a/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift similarity index 99% rename from Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift rename to Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift index 685c32d9c..7377bafc6 100644 --- a/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift @@ -1,3 +1,4 @@ +#if os(iOS) import UIKit final class TextInputStringTokenizer: UITextInputStringTokenizer { @@ -278,3 +279,4 @@ private extension CharacterSet { return character.unicodeScalars.allSatisfy(contains(_:)) } } +#endif diff --git a/Sources/Runestone/TextView/Core/TextInputView.swift b/Sources/Runestone/TextView/Core/iOS/TextInputView.swift similarity index 99% rename from Sources/Runestone/TextView/Core/TextInputView.swift rename to Sources/Runestone/TextView/Core/iOS/TextInputView.swift index 728a83f3b..130e9ac25 100644 --- a/Sources/Runestone/TextView/Core/TextInputView.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextInputView.swift @@ -1,3 +1,4 @@ +#if os(iOS) // swiftlint:disable file_length import Combine import UIKit @@ -1668,3 +1669,4 @@ extension TextInputView: EditMenuControllerDelegate { return selectedRange } } +#endif diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift new file mode 100644 index 000000000..1480ae425 --- /dev/null +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -0,0 +1,1497 @@ +// swiftlint:disable +import CoreText +//import UIKit + +/// A type similiar to UITextView with features commonly found in code editors. +/// +/// `TextView` is a performant implementation of a text view with features such as showing line numbers, searching for text and replacing results, syntax highlighting, showing invisible characters and more. +/// +/// The type does not subclass `UITextView` but its interface is kept close to `UITextView`. +/// +/// When initially configuring the `TextView` with a theme, a language and the text to be shown, it is recommended to use the ``setState(_:addUndoAction:)`` function. +/// The function takes an instance of ``TextViewState`` as input which can be created on a background queue to avoid blocking the main queue while doing the initial parse of a text. +//open class TextView: UIScrollView { +// /// Delegate to receive callbacks for events triggered by the editor. +// public weak var editorDelegate: TextViewDelegate? +// /// Whether the text view is in a state where the contents can be edited. +// public private(set) var isEditing = false { +// didSet { +// if isEditing != oldValue { +// textInputView.isEditing = isEditing +// } +// } +// } +// /// The text that the text view displays. +// public var text: String { +// get { +// return textInputView.string as String +// } +// set { +// textInputView.string = newValue as NSString +// contentSize = preferredContentSize +// } +// } +// /// A Boolean value that indicates whether the text view is editable. +// public var isEditable = true { +// didSet { +// if isEditable != oldValue && !isEditable && isEditing { +// resignFirstResponder() +// textInputViewDidEndEditing(textInputView) +// } +// } +// } +// /// A Boolean value that indicates whether the text view is selectable. +// public var isSelectable = true { +// didSet { +// if isSelectable != oldValue { +// textInputView.isUserInteractionEnabled = isSelectable +// if !isSelectable && isEditing { +// resignFirstResponder() +// textInputView.clearSelection() +// textInputViewDidEndEditing(textInputView) +// } +// } +// } +// } +// /// Colors and fonts to be used by the editor. +// public var theme: Theme { +// get { +// return textInputView.theme +// } +// set { +// textInputView.theme = newValue +// } +// } +// /// The autocorrection style for the text view. +// public var autocorrectionType: UITextAutocorrectionType { +// get { +// return textInputView.autocorrectionType +// } +// set { +// textInputView.autocorrectionType = newValue +// } +// } +// /// The autocapitalization style for the text view. +// public var autocapitalizationType: UITextAutocapitalizationType { +// get { +// return textInputView.autocapitalizationType +// } +// set { +// textInputView.autocapitalizationType = newValue +// } +// } +// /// The spell-checking style for the text view. +// public var smartQuotesType: UITextSmartQuotesType { +// get { +// return textInputView.smartQuotesType +// } +// set { +// textInputView.smartQuotesType = newValue +// } +// } +// /// The configuration state for smart dashes. +// public var smartDashesType: UITextSmartDashesType { +// get { +// return textInputView.smartDashesType +// } +// set { +// textInputView.smartDashesType = newValue +// } +// } +// /// The configuration state for the smart insertion and deletion of space characters. +// public var smartInsertDeleteType: UITextSmartInsertDeleteType { +// get { +// return textInputView.smartInsertDeleteType +// } +// set { +// textInputView.smartInsertDeleteType = newValue +// } +// } +// /// The spell-checking style for the text object. +// public var spellCheckingType: UITextSpellCheckingType { +// get { +// return textInputView.spellCheckingType +// } +// set { +// textInputView.spellCheckingType = newValue +// } +// } +// /// The keyboard type for the text view. +// public var keyboardType: UIKeyboardType { +// get { +// return textInputView.keyboardType +// } +// set { +// textInputView.keyboardType = newValue +// } +// } +// /// The appearance style of the keyboard for the text view. +// public var keyboardAppearance: UIKeyboardAppearance { +// get { +// return textInputView.keyboardAppearance +// } +// set { +// textInputView.keyboardAppearance = newValue +// } +// } +// /// The display of the return key. +// public var returnKeyType: UIReturnKeyType { +// get { +// return textInputView.returnKeyType +// } +// set { +// textInputView.returnKeyType = newValue +// } +// } +// /// Returns the undo manager used by the text view. +// override public var undoManager: UndoManager? { +// return textInputView.undoManager +// } +// /// The color of the insertion point. This can be used to control the color of the caret. +// public var insertionPointColor: UIColor { +// get { +// return textInputView.insertionPointColor +// } +// set { +// textInputView.insertionPointColor = newValue +// } +// } +// /// The color of the selection bar. It is most common to set this to the same color as the color used for the insertion point. +// public var selectionBarColor: UIColor { +// get { +// return textInputView.selectionBarColor +// } +// set { +// textInputView.selectionBarColor = newValue +// } +// } +// /// The color of the selection highlight. It is most common to set this to the same color as the color used for the insertion point. +// public var selectionHighlightColor: UIColor { +// get { +// return textInputView.selectionHighlightColor +// } +// set { +// textInputView.selectionHighlightColor = newValue +// } +// } +// /// The current selection range of the text view. +// public var selectedRange: NSRange { +// get { +// if let selectedRange = textInputView.selectedRange { +// return selectedRange +// } else { +// // UITextView returns the end of the document for the selectedRange by default. +// return NSRange(location: textInputView.string.length, length: 0) +// } +// } +// set { +// textInputView.selectedRange = newValue +// } +// } +// /// The current selection range of the text view as a UITextRange. +// public var selectedTextRange: UITextRange? { +// get { +// return textInputView.selectedTextRange +// } +// set { +// textInputView.selectedTextRange = newValue +// } +// } +// /// The custom input accessory view to display when the receiver becomes the first responder. +// override public var inputAccessoryView: UIView? { +// get { +// if isInputAccessoryViewEnabled { +// return _inputAccessoryView +// } else { +// return nil +// } +// } +// set { +// _inputAccessoryView = newValue +// } +// } +// /// The input assistant to use when configuring the keyboard's shortcuts bar. +// override public var inputAssistantItem: UITextInputAssistantItem { +// return textInputView.inputAssistantItem +// } +// /// Returns a Boolean value indicating whether this object can become the first responder. +// override public var canBecomeFirstResponder: Bool { +// return !textInputView.isFirstResponder && isEditable +// } +// /// The text view's background color. +// override public var backgroundColor: UIColor? { +// get { +// return textInputView.backgroundColor +// } +// set { +// super.backgroundColor = newValue +// textInputView.backgroundColor = newValue +// } +// } +// /// The point at which the origin of the content view is offset from the origin of the scroll view. +// override public var contentOffset: CGPoint { +// didSet { +// if contentOffset != oldValue { +// textInputView.viewport = CGRect(origin: contentOffset, size: frame.size) +// } +// } +// } +// /// Character pairs are used by the editor to automatically insert a trailing character when the user types the leading character. +// /// +// /// Common usages of this includes the \" character to surround strings and { } to surround a scope. +// public var characterPairs: [CharacterPair] { +// get { +// return textInputView.characterPairs +// } +// set { +// textInputView.characterPairs = newValue +// } +// } +// /// Determines what should happen to the trailing component of a character pair when deleting the leading component. Defaults to `disabled` meaning that nothing will happen. +// public var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode { +// get { +// return textInputView.characterPairTrailingComponentDeletionMode +// } +// set { +// textInputView.characterPairTrailingComponentDeletionMode = newValue +// } +// } +// /// Enable to show line numbers in the gutter. +// public var showLineNumbers: Bool { +// get { +// return textInputView.showLineNumbers +// } +// set { +// textInputView.showLineNumbers = newValue +// } +// } +// /// Enable to show highlight the selected lines. The selection is only shown in the gutter when multiple lines are selected. +// public var lineSelectionDisplayType: LineSelectionDisplayType { +// get { +// return textInputView.lineSelectionDisplayType +// } +// set { +// textInputView.lineSelectionDisplayType = newValue +// } +// } +// /// The text view renders invisible tabs when enabled. The `tabsSymbol` is used to render tabs. +// public var showTabs: Bool { +// get { +// return textInputView.showTabs +// } +// set { +// textInputView.showTabs = newValue +// } +// } +// /// The text view renders invisible spaces when enabled. +// /// +// /// he `spaceSymbol` is used to render spaces. +// public var showSpaces: Bool { +// get { +// return textInputView.showSpaces +// } +// set { +// textInputView.showSpaces = newValue +// } +// } +// /// The text view renders invisible spaces when enabled. +// /// +// /// The `nonBreakingSpaceSymbol` is used to render spaces. +// public var showNonBreakingSpaces: Bool { +// get { +// return textInputView.showNonBreakingSpaces +// } +// set { +// textInputView.showNonBreakingSpaces = newValue +// } +// } +// /// The text view renders invisible line breaks when enabled. +// /// +// /// The `lineBreakSymbol` is used to render line breaks. +// public var showLineBreaks: Bool { +// get { +// return textInputView.showLineBreaks +// } +// set { +// textInputView.showLineBreaks = newValue +// } +// } +// /// The text view renders invisible soft line breaks when enabled. +// /// +// /// The `softLineBreakSymbol` is used to render line breaks. These line breaks are typically represented by the U+2028 unicode character. Runestone does not provide any key commands for inserting these but supports rendering them. +// public var showSoftLineBreaks: Bool { +// get { +// return textInputView.showSoftLineBreaks +// } +// set { +// textInputView.showSoftLineBreaks = newValue +// } +// } +// /// Symbol used to display tabs. +// /// +// /// The value is only used when invisible tab characters is enabled. The default is ▸. +// /// +// /// Common characters for this symbol include ▸, ⇥, ➜, ➞, and ❯. +// public var tabSymbol: String { +// get { +// return textInputView.tabSymbol +// } +// set { +// textInputView.tabSymbol = newValue +// } +// } +// /// Symbol used to display spaces. +// /// +// /// The value is only used when showing invisible space characters is enabled. The default is ·. +// /// +// /// Common characters for this symbol include ·, •, and _. +// public var spaceSymbol: String { +// get { +// return textInputView.spaceSymbol +// } +// set { +// textInputView.spaceSymbol = newValue +// } +// } +// /// Symbol used to display non-breaking spaces. +// /// +// /// The value is only used when showing invisible space characters is enabled. The default is ·. +// /// +// /// Common characters for this symbol include ·, •, and _. +// public var nonBreakingSpaceSymbol: String { +// get { +// return textInputView.nonBreakingSpaceSymbol +// } +// set { +// textInputView.nonBreakingSpaceSymbol = newValue +// } +// } +// /// Symbol used to display line break. +// /// +// /// The value is only used when showing invisible line break characters is enabled. The default is ¬. +// /// +// /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. +// public var lineBreakSymbol: String { +// get { +// return textInputView.lineBreakSymbol +// } +// set { +// textInputView.lineBreakSymbol = newValue +// } +// } +// /// Symbol used to display soft line breaks. +// /// +// /// The value is only used when showing invisible soft line break characters is enabled. The default is ¬. +// /// +// /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. +// public var softLineBreakSymbol: String { +// get { +// return textInputView.softLineBreakSymbol +// } +// set { +// textInputView.softLineBreakSymbol = newValue +// } +// } +// /// The strategy used when indenting text. +// public var indentStrategy: IndentStrategy { +// get { +// return textInputView.indentStrategy +// } +// set { +// textInputView.indentStrategy = newValue +// } +// } +// /// The amount of padding before the line numbers inside the gutter. +// public var gutterLeadingPadding: CGFloat { +// get { +// return textInputView.gutterLeadingPadding +// } +// set { +// textInputView.gutterLeadingPadding = newValue +// } +// } +// /// The amount of padding after the line numbers inside the gutter. +// public var gutterTrailingPadding: CGFloat { +// get { +// return textInputView.gutterTrailingPadding +// } +// set { +// textInputView.gutterTrailingPadding = newValue +// } +// } +// /// The minimum amount of characters to use for width calculation inside the gutter. +// public var gutterMinimumCharacterCount: Int { +// get { +// return textInputView.gutterMinimumCharacterCount +// } +// set { +// textInputView.gutterMinimumCharacterCount = newValue +// } +// } +// /// The amount of spacing surrounding the lines. +// public var textContainerInset: UIEdgeInsets { +// get { +// return textInputView.textContainerInset +// } +// set { +// textInputView.textContainerInset = newValue +// } +// } +// /// When line wrapping is disabled, users can scroll the text view horizontally to see the entire line. +// /// +// /// Line wrapping is enabled by default. +// public var isLineWrappingEnabled: Bool { +// get { +// return textInputView.isLineWrappingEnabled +// } +// set { +// textInputView.isLineWrappingEnabled = newValue +// } +// } +// /// Line break mode for text view. The default value is .byWordWrapping meaning that wrapping occurs on word boundaries. +// public var lineBreakMode: LineBreakMode { +// get { +// return textInputView.lineBreakMode +// } +// set { +// textInputView.lineBreakMode = newValue +// } +// } +// /// Width of the gutter. +// public var gutterWidth: CGFloat { +// return textInputView.gutterWidth +// } +// /// The line-height is multiplied with the value. +// public var lineHeightMultiplier: CGFloat { +// get { +// return textInputView.lineHeightMultiplier +// } +// set { +// textInputView.lineHeightMultiplier = newValue +// } +// } +// /// The number of points by which to adjust kern. The default value is 0 meaning that kerning is disabled. +// public var kern: CGFloat { +// get { +// return textInputView.kern +// } +// set { +// textInputView.kern = newValue +// } +// } +// /// The text view shows a page guide when enabled. Use `pageGuideColumn` to specify the location of the page guide. +// public var showPageGuide: Bool { +// get { +// return textInputView.showPageGuide +// } +// set { +// textInputView.showPageGuide = newValue +// } +// } +// /// Specifies the location of the page guide. Use `showPageGuide` to specify if the page guide should be shown. +// public var pageGuideColumn: Int { +// get { +// return textInputView.pageGuideColumn +// } +// set { +// textInputView.pageGuideColumn = newValue +// } +// } +// /// Automatically scrolls the text view to show the caret when typing or moving the caret. +// public var isAutomaticScrollEnabled = true +// /// Amount of overscroll to add in the vertical direction. +// /// +// /// The overscroll is a factor of the scrollable area height and will not take into account any insets. 0 means no overscroll and 1 means an amount equal to the height of the text view. Detaults to 0. +// public var verticalOverscrollFactor: CGFloat = 0 { +// didSet { +// if horizontalOverscrollFactor != oldValue { +// hasPendingContentSizeUpdate = true +// handleContentSizeUpdateIfNeeded() +// } +// } +// } +// /// Amount of overscroll to add in the horizontal direction. +// /// +// /// The overscroll is a factor of the scrollable area height and will not take into account any insets or the width of the gutter. 0 means no overscroll and 1 means an amount equal to the width of the text view. Detaults to 0. +// public var horizontalOverscrollFactor: CGFloat = 0 { +// didSet { +// if horizontalOverscrollFactor != oldValue { +// hasPendingContentSizeUpdate = true +// handleContentSizeUpdateIfNeeded() +// } +// } +// } +// /// 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? { +// return 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 { +// return textInputView.highlightedRanges +// } +// set { +// textInputView.highlightedRanges = newValue +// highlightNavigationController.highlightedRanges = newValue +// } +// } +// /// Wheter the text view should loop when navigating through highlighted ranges using `selectPreviousHighlightedRange` or `selectNextHighlightedRange` on the text view. +// public var highlightedRangeLoopingMode: HighlightedRangeLoopingMode { +// get { +// if highlightNavigationController.loopRanges { +// return .enabled +// } else { +// return .disabled +// } +// } +// set { +// switch newValue { +// case .enabled: +// highlightNavigationController.loopRanges = true +// case .disabled: +// highlightNavigationController.loopRanges = false +// } +// } +// } +// /// Line endings to use when inserting a line break. +// /// +// /// The value only affects new line breaks inserted in the text view and changing this value does not change the line endings of the text in the text view. Defaults to Unix (LF). +// /// +// /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)`` and ``TextView/replaceText(in:)``. +// public var lineEndings: LineEnding { +// get { +// return textInputView.lineEndings +// } +// set { +// textInputView.lineEndings = newValue +// } +// } +//#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. +// @available(iOS 16, *) +// public var isFindInteractionEnabled: Bool { +// get { +// return textSearchingHelper.isFindInteractionEnabled +// } +// set { +// textSearchingHelper.isFindInteractionEnabled = newValue +// } +// } +// /// The text view’s built-in find interaction. +// /// +// /// Set to true to enable the text view's built-in find interaction. This method returns nil when the interaction isn't enabled. +// /// +// /// Call [`presentFindNavigator(showingReplace:)`](https://developer.apple.com/documentation/uikit/uifindinteraction/3975832-presentfindnavigator) on the UIFindInteraction object to invoke the find interaction and display the find panel. +// @available(iOS 16, *) +// public var findInteraction: UIFindInteraction? { +// return 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? { +// return _editMenuInteraction as? UIEditMenuInteraction +// } +// private var _editMenuInteraction: Any? +//#endif +// private let tapGestureRecognizer = QuickTapGestureRecognizer() +// private var _inputAccessoryView: UIView? +// private let _inputAssistantItem = UITextInputAssistantItem() +// private var isPerformingNonEditableTextInteraction = false +// private var delegateAllowsEditingToBegin: Bool { +// guard isEditable else { +// return false +// } +// if let editorDelegate = editorDelegate { +// return editorDelegate.textViewShouldBeginEditing(self) +// } else { +// return true +// } +// } +// private var shouldEndEditing: Bool { +// if let editorDelegate = editorDelegate { +// return editorDelegate.textViewShouldEndEditing(self) +// } else { +// return true +// } +// } +// private var hasPendingContentSizeUpdate = false +// private var isInputAccessoryViewEnabled = false +// private let keyboardObserver = KeyboardObserver() +// private let highlightNavigationController = HighlightNavigationController() +// private var textSearchingHelper = UITextSearchingHelper() +// // Store a reference to instances of the private type UITextRangeAdjustmentGestureRecognizer in order to track adjustments +// // to the selected text range and scroll the text view when the handles approach the bottom. +// // The approach is based on the one described in Steve Shephard's blog post "Adventures with UITextInteraction". +// // https://steveshepard.com/blog/adventures-with-uitextinteraction/ +// private var textRangeAdjustmentGestureRecognizers: Set = [] +// private var previousSelectedRangeDuringGestureHandling: NSRange? +// private var preferredContentSize: CGSize { +// let horizontalOverscrollLength = max(frame.width * horizontalOverscrollFactor, 0) +// let verticalOverscrollLength = max(frame.height * verticalOverscrollFactor, 0) +// let baseContentSize = textInputView.contentSize +// let width = isLineWrappingEnabled ? baseContentSize.width : baseContentSize.width + horizontalOverscrollLength +// let height = baseContentSize.height + verticalOverscrollLength +// return CGSize(width: width, height: height) +// } +// +// /// Create a new text view. +// /// - Parameter frame: The frame rectangle of the text view. +// override public init(frame: CGRect) { +// textInputView = TextInputView(theme: DefaultTheme()) +// super.init(frame: frame) +// backgroundColor = .white +// textInputView.delegate = self +// textInputView.gutterParentView = self +// editableTextInteraction.textInput = textInputView +// nonEditableTextInteraction.textInput = textInputView +// editableTextInteraction.delegate = self +// nonEditableTextInteraction.delegate = self +// addSubview(textInputView) +// tapGestureRecognizer.delegate = self +// tapGestureRecognizer.addTarget(self, action: #selector(handleTap(_:))) +// addGestureRecognizer(tapGestureRecognizer) +// installNonEditableInteraction() +// keyboardObserver.delegate = self +// highlightNavigationController.delegate = self +// textSearchingHelper.textView = self +// } +// +// /// The initializer has not been implemented. +// /// - Parameter coder: Not used. +// public required init?(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// /// Lays out subviews. +// override open func layoutSubviews() { +// super.layoutSubviews() +// handleContentSizeUpdateIfNeeded() +// textInputView.scrollViewWidth = frame.width +// textInputView.frame = CGRect(x: 0, y: 0, width: max(contentSize.width, frame.width), height: max(contentSize.height, frame.height)) +// textInputView.viewport = CGRect(origin: contentOffset, size: frame.size) +// bringSubviewToFront(textInputView.gutterContainerView) +// } +// +// /// Called when the safe area of the view changes. +// override open func safeAreaInsetsDidChange() { +// super.safeAreaInsetsDidChange() +// textInputView.scrollViewSafeAreaInsets = safeAreaInsets +// contentSize = preferredContentSize +// layoutIfNeeded() +// } +// +// /// Asks UIKit to make this object the first responder in its window. +// @discardableResult +// override open func becomeFirstResponder() -> Bool { +// if !isEditing && delegateAllowsEditingToBegin { +// _ = textInputView.resignFirstResponder() +// _ = textInputView.becomeFirstResponder() +// return true +// } else { +// return false +// } +// } +// +// /// Notifies this object that it has been asked to relinquish its status as first responder in its window. +// @discardableResult +// override open func resignFirstResponder() -> Bool { +// if isEditing && shouldEndEditing { +// return textInputView.resignFirstResponder() +// } else { +// return false +// } +// } +// +// /// Updates the custom input and accessory views when the object is the first responder. +// override open func reloadInputViews() { +// textInputView.reloadInputViews() +// } +// +// /// Sets the current _state_ of the editor. The state contains the text to be displayed by the editor and +// /// various additional information about the text that the editor needs to show the text. +// /// +// /// It is safe to create an instance of TextViewState in the background, and as such it can be +// /// created before presenting the editor to the user, e.g. when opening the document from an instance of +// /// UIDocumentBrowserViewController. +// /// +// /// This is the preferred way to initially set the text, language and theme on the TextView. +// /// - Parameter state: The new state to be used by the editor. +// /// - Parameter addUndoAction: Whether the state change can be undone. Defaults to false. +// public func setState(_ state: TextViewState, addUndoAction: Bool = false) { +// textInputView.setState(state, addUndoAction: addUndoAction) +// contentSize = preferredContentSize +// } +// +// /// Returns the row and column at the specified location in the text. +// /// Common usages of this includes showing the line and column that the caret is currently located at. +// /// - Parameter location: The location is relative to the first index in the string. +// /// - Returns: The text location if the input location could be found in the string, otherwise nil. +// public func textLocation(at location: Int) -> TextLocation? { +// if let linePosition = textInputView.linePosition(at: location) { +// return TextLocation(linePosition) +// } else { +// return nil +// } +// } +// +// /// Returns the character location at the specified row and column. +// /// - Parameter textLocation: The row and column in the text. +// /// - Returns: The location if the input row and column could be found in the text, otherwise nil. +// public func location(at textLocation: TextLocation) -> Int? { +// let lineIndex = textLocation.lineNumber +// guard lineIndex >= 0 && lineIndex < textInputView.lineManager.lineCount else { +// return nil +// } +// let line = textInputView.lineManager.line(atRow: lineIndex) +// guard textLocation.column >= 0 && textLocation.column <= line.data.totalLength else { +// return nil +// } +// return line.location + textLocation.column +// } +// +// /// Sets the language mode on a background thread. +// /// +// /// - Parameters: +// /// - languageMode: The new language mode to be used by the editor. +// /// - completion: Called when the content have been parsed or when parsing fails. +// public func setLanguageMode(_ languageMode: LanguageMode, completion: ((Bool) -> Void)? = nil) { +// textInputView.setLanguageMode(languageMode, completion: completion) +// } +// +// /// Inserts text at the location of the caret or, if no selection or caret is present, at the end of the text. +// /// - Parameter text: A string to insert. +// open func insertText(_ text: String) { +// textInputView.inputDelegate?.selectionWillChange(textInputView) +// textInputView.insertText(text) +// textInputView.inputDelegate?.selectionDidChange(textInputView) +// } +// +// /// Replaces the text that is in the specified range. +// /// - Parameters: +// /// - range: A range of text in the document. +// /// - text: A string to replace the text in range. +// open func replace(_ range: UITextRange, withText text: String) { +// textInputView.inputDelegate?.selectionWillChange(textInputView) +// textInputView.replace(range, withText: text) +// textInputView.inputDelegate?.selectionDidChange(textInputView) +// } +// +// /// Replaces the text that is in the specified range. +// /// - Parameters: +// /// - range: A range of text in the document. +// /// - text: A string to replace the text in range. +// public func replace(_ range: NSRange, withText text: String) { +// textInputView.inputDelegate?.selectionWillChange(textInputView) +// let indexedRange = IndexedRange(range) +// textInputView.replace(indexedRange, withText: text) +// textInputView.inputDelegate?.selectionDidChange(textInputView) +// } +// +// /// Replaces the text in the specified matches. +// /// - Parameters: +// /// - batchReplaceSet: Set of ranges to replace with a text. +// public func replaceText(in batchReplaceSet: BatchReplaceSet) { +// textInputView.replaceText(in: batchReplaceSet) +// } +// +// /// Deletes a character from the displayed text. +// public func deleteBackward() { +// textInputView.deleteBackward() +// } +// +// /// Returns the text in the specified range. +// /// - Parameter range: A range of text in the document. +// /// - Returns: The substring that falls within the specified range. +// public func text(in range: NSRange) -> String? { +// return textInputView.text(in: range) +// } +// +// /// Returns the syntax node at the specified location in the document. +// /// +// /// This can be used with character pairs to determine if a pair should be inserted or not. +// /// For example, a character pair consisting of two quotes (") to surround a string, should probably not be +// /// inserted when the quote is typed while the caret is already inside a string. +// /// +// /// This requires a language to be set on the editor. +// /// - Parameter location: A location in the document. +// /// - Returns: The syntax node at the location. +// public func syntaxNode(at location: Int) -> SyntaxNode? { +// return textInputView.syntaxNode(at: location) +// } +// +// /// Checks if the specified locations is within the indentation of the line. +// /// +// /// - Parameter location: A location in the document. +// /// - Returns: True if the location is within the indentation of the line, otherwise false. +// public func isIndentation(at location: Int) -> Bool { +// return textInputView.isIndentation(at: location) +// } +// +// /// Decreases the indentation level of the selected lines. +// public func shiftLeft() { +// textInputView.shiftLeft() +// } +// +// /// Increases the indentation level of the selected lines. +// public func shiftRight() { +// textInputView.shiftRight() +// } +// +// /// Moves the selected lines up by one line. +// /// +// /// Calling this function has no effect when the selected lines include the first line in the text view. +// public func moveSelectedLinesUp() { +// textInputView.moveSelectedLinesUp() +// } +// +// /// Moves the selected lines down by one line. +// /// +// /// Calling this function has no effect when the selected lines include the last line in the text view. +// public func moveSelectedLinesDown() { +// textInputView.moveSelectedLinesDown() +// } +// +// /// Attempts to detect the indent strategy used in the document. This may return an unknown strategy even +// /// when the document contains indentation. +// public func detectIndentStrategy() -> DetectedIndentStrategy { +// return textInputView.detectIndentStrategy() +// } +// +// /// Go to the beginning of the line at the specified index. +// /// +// /// - Parameter lineIndex: Index of line to navigate to. +// /// - Parameter selection: The placement of the caret on the line. +// /// - Returns: True if the text view could navigate to the specified line index, otherwise false. +// @discardableResult +// public func goToLine(_ lineIndex: Int, select selection: GoToLineSelection = .beginning) -> Bool { +// guard lineIndex >= 0 && lineIndex < textInputView.lineManager.lineCount else { +// return false +// } +// // I'm not exactly sure why this is necessary but if the text view is the first responder as we jump +// // to the line and we don't resign the first responder first, the caret will disappear after we have +// // jumped to the specified line. +// resignFirstResponder() +// becomeFirstResponder() +// let line = textInputView.lineManager.line(atRow: lineIndex) +// textInputView.layoutLines(toLocation: line.location) +// scrollLocationToVisible(line.location) +// layoutIfNeeded() +// switch selection { +// case .beginning: +// textInputView.selectedRange = NSRange(location: line.location, length: 0) +// case .end: +// textInputView.selectedRange = NSRange(location: line.data.length, length: line.data.length) +// case .line: +// textInputView.selectedRange = NSRange(location: line.location, length: line.data.length) +// } +// return true +// } +// +// /// Search for the specified query. +// /// +// /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:)``. +// /// +// /// ```swift +// /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) +// /// let results = textView.search(for: query) +// /// ``` +// /// +// /// - Parameter query: Query to find matches for. +// /// - Returns: Results matching the query. +// public func search(for query: SearchQuery) -> [SearchResult] { +// let searchController = SearchController(stringView: textInputView.stringView) +// searchController.delegate = self +// return searchController.search(for: query) +// } +// +// /// Search for the specified query and return results that take a replacement string into account. +// /// +// /// When searching for a regular expression this function will perform pattern matching and take the matched groups into account in the returned results. +// /// +// /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:replacingMatchesWith:)`` and how the returned search results can be used to perform a replace operation. +// /// +// /// ```swift +// /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) +// /// let results = textView.search(for: query, replacingMatchesWith: "bar") +// /// let replacements = results.map { BatchReplaceSet.Replacement(range: $0.range, text: $0.replacementText) } +// /// let batchReplaceSet = BatchReplaceSet(replacements: replacements) +// /// textView.replaceText(in: batchReplaceSet) +// /// ``` +// /// +// /// - Parameters: +// /// - query: Query to find matches for. +// /// - replacementString: String to replace matches with. Can refer to groups in a regular expression using $0, $1, $2 etc. +// /// - Returns: Results matching the query. +// public func search(for query: SearchQuery, replacingMatchesWith replacementString: String) -> [SearchReplaceResult] { +// let searchController = SearchController(stringView: textInputView.stringView) +// searchController.delegate = self +// return searchController.search(for: query, replacingMatchesWith: replacementString) +// } +// +// /// Returns a peek into the text view's underlying attributed string. +// /// - Parameter range: Range of text to include in text view. The returned result may span a larger range than the one specified. +// /// - Returns: Text preview containing the specified range. +// public func textPreview(containing range: NSRange) -> TextPreview? { +// return textInputView.textPreview(containing: range) +// } +// +// /// Selects a highlighted range behind the selected range if possible. +// public func selectPreviousHighlightedRange() { +// highlightNavigationController.selectPreviousRange() +// } +// +// /// Selects a highlighted range after the selected range if possible. +// public func selectNextHighlightedRange() { +// highlightNavigationController.selectNextRange() +// } +// +// /// Selects the highlighed range at the specified index. +// /// - Parameter index: Index of highlighted range to select. +// public func selectHighlightedRange(at index: Int) { +// highlightNavigationController.selectRange(at: index) +// } +// +// /// Synchronously displays the visible lines. This can be used to immediately update the visible lines after setting the theme. Use with caution as this redisplaying the visible lines can be a costly operation. +// public func redisplayVisibleLines() { +// textInputView.redisplayVisibleLines() +// } +//} +// +//// MARK: - UITextInput +//extension TextView { +// /// The range of currently marked text in a document. +// public var markedTextRange: UITextRange? { +// return textInputView.markedTextRange +// } +// +// /// The text position for the beginning of a document. +// public var beginningOfDocument: UITextPosition { +// return textInputView.beginningOfDocument +// } +// +// /// The text position for the end of a document. +// public var endOfDocument: UITextPosition { +// return textInputView.endOfDocument +// } +// +// /// Returns the range between two text positions. +// /// - Parameters: +// /// - fromPosition: An object that represents a location in a document. +// /// - toPosition: An object that represents another location in a document. +// /// - Returns: An object that represents the range between fromPosition and toPosition. +// public func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { +// return textInputView.textRange(from: fromPosition, to: toPosition) +// } +// +// /// Returns the text position at a specified offset from another text position. +// /// - Parameters: +// /// - position: A custom UITextPosition object that represents a location in a document. +// /// - offset: A character offset from position. It can be a positive or negative value. +// /// - Returns: A custom UITextPosition object that represents the location in a document that is at the specified offset from position. Returns nil if the computed text position is less than 0 or greater than the length of the backing string. +// public func position(from position: UITextPosition, offset: Int) -> UITextPosition? { +// return textInputView.position(from: position, offset: offset) +// } +// +// /// Returns the text position at a specified offset in a specified direction from another text position. +// /// - Parameters: +// /// - position: A custom UITextPosition object that represents a location in a document. +// /// - direction: A UITextLayoutDirection constant that represents the direction of the offset from position. +// /// - offset: A character offset from position. +// /// - Returns: Returns the text position at a specified offset in a specified direction from another text position. Returns nil if the computed text position is less than 0 or greater than the length of the backing string. +// public func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? { +// return textInputView.position(from: position, in: direction, offset: offset) +// } +// +// /// Returns how one text position compares to another text position. +// /// - Parameters: +// /// - position: A custom object that represents a location within a document. +// /// - other: A custom object that represents another location within a document. +// /// - Returns: A value that indicates whether the two text positions are identical or whether one is before the other. +// public func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { +// return textInputView.compare(position, to: other) +// } +// +// /// Returns the number of UTF-16 characters between one text position and another text position. +// /// - Parameters: +// /// - from: A custom object that represents a location within a document. +// /// - toPosition: A custom object that represents another location within document. +// /// - Returns: The number of UTF-16 characters between fromPosition and toPosition. +// public func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { +// return textInputView.offset(from: from, to: toPosition) +// } +// +// /// An input tokenizer that provides information about the granularity of text units. +// public var tokenizer: UITextInputTokenizer { +// return textInputView.tokenizer +// } +// +// /// Returns the text position that is at the farthest extent in a specified layout direction within a range of text. +// /// - Parameters: +// /// - range: A text-range object that demarcates a range of text in a document. +// /// - direction: A constant that indicates a direction of layout (right, left, up, down). +// /// - Returns: A text-position object that identifies a location in the visible text. +// public func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { +// return textInputView.position(within: range, farthestIn: direction) +// } +// +// /// Returns a text range from a specified text position to its farthest extent in a certain direction of layout. +// /// - Parameters: +// /// - position: A text-position object that identifies a location in a document. +// /// - direction: A constant that indicates a direction of layout (right, left, up, down). +// /// - Returns: A text-range object that represents the distance from position to the farthest extent in direction. +// public func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { +// return textInputView.characterRange(byExtending: position, in: direction) +// } +// +// /// Returns the first rectangle that encloses a range of text in a document. +// /// - Parameter range: An object that represents a range of text in a document. +// /// - Returns: The first rectangle in a range of text. You might use this rectangle to draw a correction rectangle. The “first” in the name refers the rectangle enclosing the first line when the range encompasses multiple lines of text. +// public func firstRect(for range: UITextRange) -> CGRect { +// return textInputView.firstRect(for: range) +// } +// +// /// Returns a rectangle to draw the caret at a specified insertion point. +// /// - Parameter position: An object that identifies a location in a text input area. +// /// - Returns: A rectangle that defines the area for drawing the caret. +// public func caretRect(for position: UITextPosition) -> CGRect { +// return textInputView.caretRect(for: position) +// } +// +// /// Returns an array of selection rects corresponding to the range of text. +// /// - Parameter range: An object representing a range in a document’s text. +// /// - Returns: An array of UITextSelectionRect objects that encompass the selection. +// public func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { +// return textInputView.selectionRects(for: range) +// } +// +// /// Returns the position in a document that is closest to a specified point. +// /// - Parameter point: A point in the view that is drawing a document’s text. +// /// - Returns: An object locating a position in a document that is closest to point. +// public func closestPosition(to point: CGPoint) -> UITextPosition? { +// return textInputView.closestPosition(to: point) +// } +// +// /// Returns the position in a document that is closest to a specified point in a specified range. +// /// - Parameters: +// /// - point: A point in the view that is drawing a document’s text. +// /// - range: An object representing a range in a document’s text. +// /// - Returns: An object representing the character position in range that is closest to point. +// public func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { +// return textInputView.closestPosition(to: point, within: range) +// } +// +// /// Returns the character or range of characters that is at a specified point in a document. +// /// - Parameter point: A point in the view that is drawing a document’s text. +// /// - Returns: An object representing a range that encloses a character (or characters) at point. +// public func characterRange(at point: CGPoint) -> UITextRange? { +// return textInputView.characterRange(at: point) +// } +// +// /// Returns the text in the specified range. +// /// - Parameter range: A range of text in a document. +// /// - Returns: A substring of a document that falls within the specified range. +// public func text(in range: UITextRange) -> String? { +// return textInputView.text(in: range) +// } +// +// /// A Boolean value that indicates whether the text-entry object has any text. +// public var hasText: Bool { +// return textInputView.hasText +// } +// +// /// Scrolls the text view to reveal the text in the specified range. +// /// +// /// The function will scroll the text view as little as possible while revealing as much as possible of the specified range. It is not guaranteed that the entire range is visible after performing the scroll. +// /// +// /// - Parameters: +// /// - range: The range of text to scroll into view. +// public func scrollRangeToVisible(_ range: NSRange) { +// textInputView.layoutLines(toLocation: range.upperBound) +// justScrollRangeToVisible(range) +// } +//} +// +//private extension TextView { +// @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { +// guard isSelectable else { +// return +// } +// if gestureRecognizer.state == .ended { +// let point = gestureRecognizer.location(in: textInputView) +// let oldSelectedRange = textInputView.selectedRange +// textInputView.moveCaret(to: point) +// if textInputView.selectedRange != oldSelectedRange { +// layoutIfNeeded() +// } +// installEditableInteraction() +// becomeFirstResponder() +// } +// } +// +// @objc private func handleTextRangeAdjustmentPan(_ gestureRecognizer: UIPanGestureRecognizer) { +// // This function scroll the text view when the selected range is adjusted. +// if gestureRecognizer.state == .began { +// previousSelectedRangeDuringGestureHandling = selectedRange +// } else if gestureRecognizer.state == .changed, let previousSelectedRange = previousSelectedRangeDuringGestureHandling { +// if selectedRange.lowerBound != previousSelectedRange.lowerBound { +// // User is adjusting the lower bound (location) of the selected range. +// scrollLocationToVisible(selectedRange.lowerBound) +// } else if selectedRange.upperBound != previousSelectedRange.upperBound { +// // User is adjusting the upper bound (length) of the selected range. +// scrollLocationToVisible(selectedRange.upperBound) +// } +// previousSelectedRangeDuringGestureHandling = selectedRange +// } +// } +// +// private func insertLeadingComponent(of characterPair: CharacterPair, in range: NSRange) -> Bool { +// let shouldInsertCharacterPair = editorDelegate?.textView(self, shouldInsert: characterPair, in: range) ?? true +// guard shouldInsertCharacterPair else { +// return false +// } +// guard let selectedRange = textInputView.selectedRange else { +// return false +// } +// if selectedRange.length == 0 { +// textInputView.insertText(characterPair.leading + characterPair.trailing) +// textInputView.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: 0) +// return true +// } else if let text = textInputView.text(in: selectedRange) { +// let modifiedText = characterPair.leading + text + characterPair.trailing +// let indexedRange = IndexedRange(selectedRange) +// textInputView.replace(indexedRange, withText: modifiedText) +// textInputView.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: range.length) +// return true +// } else { +// return false +// } +// } +// +// private func skipInsertingTrailingComponent(of characterPair: CharacterPair, in range: NSRange) -> Bool { +// // When typing the trailing component of a character pair, e.g. ) or } and the cursor is just in front of that character, +// // the delegate is asked whether the text view should skip inserting that character. If the character is skipped, +// // then the caret is moved after the trailing character component. +// let followingTextRange = NSRange(location: range.location + range.length, length: characterPair.trailing.count) +// let followingText = textInputView.text(in: followingTextRange) +// guard followingText == characterPair.trailing else { +// return false +// } +// let shouldSkip = editorDelegate?.textView(self, shouldSkipTrailingComponentOf: characterPair, in: range) ?? true +// if shouldSkip { +// moveCaret(byOffset: characterPair.trailing.count) +// return true +// } else { +// return false +// } +// } +// +// private func moveCaret(byOffset offset: Int) { +// if let selectedRange = textInputView.selectedRange { +// textInputView.selectedRange = NSRange(location: selectedRange.location + offset, length: 0) +// } +// } +// +// private func handleContentSizeUpdateIfNeeded() { +// if hasPendingContentSizeUpdate { +// // We don't want to update the content size when the scroll view is "bouncing" near the gutter, +// // or at the end of a line since it causes flickering when updating the content size while scrolling. +// // However, we do allow updating the content size if the text view is scrolled far enough on +// // the y-axis as that means it will soon run out of text to display. +// let isBouncingAtGutter = contentOffset.x < -contentInset.left +// let isBouncingAtLineEnd = contentOffset.x > contentSize.width - frame.size.width + contentInset.right +// let isBouncingHorizontally = isBouncingAtGutter || isBouncingAtLineEnd +// let isCriticalUpdate = contentOffset.y > contentSize.height - frame.height * 1.5 +// let isScrolling = isDragging || isDecelerating +// if !isBouncingHorizontally || isCriticalUpdate || !isScrolling { +// hasPendingContentSizeUpdate = false +// let oldContentOffset = contentOffset +// contentSize = preferredContentSize +// contentOffset = oldContentOffset +// setNeedsLayout() +// } +// } +// } +// +// private func justScrollRangeToVisible(_ range: NSRange) { +// let lowerBoundRect = textInputView.caretRect(at: range.lowerBound) +// let upperBoundRect = range.length == 0 ? lowerBoundRect : textInputView.caretRect(at: range.upperBound) +// let rectMinX = min(lowerBoundRect.minX, upperBoundRect.minX) +// let rectMaxX = max(lowerBoundRect.maxX, upperBoundRect.maxX) +// let rectMinY = min(lowerBoundRect.minY, upperBoundRect.minY) +// let rectMaxY = max(lowerBoundRect.maxY, upperBoundRect.maxY) +// let rect = CGRect(x: rectMinX, y: rectMinY, width: rectMaxX - rectMinX, height: rectMaxY - rectMinY) +// contentOffset = contentOffsetForScrollingToVisibleRect(rect) +// } +// +// private func scrollLocationToVisible(_ location: Int) { +// let range = NSRange(location: location, length: 0) +// justScrollRangeToVisible(range) +// } +// +// private func installEditableInteraction() { +// if editableTextInteraction.view == nil { +// isInputAccessoryViewEnabled = true +// textInputView.removeInteraction(nonEditableTextInteraction) +// textInputView.addInteraction(editableTextInteraction) +// } +// } +// +// private func installNonEditableInteraction() { +// if nonEditableTextInteraction.view == nil { +// isInputAccessoryViewEnabled = false +// textInputView.removeInteraction(editableTextInteraction) +// textInputView.addInteraction(nonEditableTextInteraction) +// for gestureRecognizer in nonEditableTextInteraction.gesturesForFailureRequirements { +// gestureRecognizer.require(toFail: tapGestureRecognizer) +// } +// } +// } +// +// /// Computes a content offset to scroll to in order to reveal the specified rectangle. +// /// +// /// The function will return a rectangle that scrolls the text view a minimum amount while revealing as much as possible of the rectangle. It is not guaranteed that the entire rectangle can be revealed. +// /// - Parameter rect: The rectangle to reveal. +// /// - Returns: The content offset to scroll to. +// private func contentOffsetForScrollingToVisibleRect(_ rect: CGRect) -> CGPoint { +// // Create the viewport: a rectangle containing the content that is visible to the user. +// var viewport = CGRect(x: contentOffset.x, y: contentOffset.y, width: frame.width, height: frame.height) +// viewport.origin.y += adjustedContentInset.top +// viewport.origin.x += adjustedContentInset.left + gutterWidth +// viewport.size.width -= adjustedContentInset.left + adjustedContentInset.right + gutterWidth +// viewport.size.height -= adjustedContentInset.top + adjustedContentInset.bottom +// // Construct the best possible content offset. +// var newContentOffset = contentOffset +// if rect.minX < viewport.minX { +// newContentOffset.x -= viewport.minX - rect.minX +// } else if rect.maxX > viewport.maxX && rect.width <= viewport.width { +// // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. +// newContentOffset.x += rect.maxX - viewport.maxX +// } else if rect.maxX > viewport.maxX { +// newContentOffset.x += rect.minX +// } +// if rect.minY < viewport.minY { +// newContentOffset.y -= viewport.minY - rect.minY +// } else if rect.maxY > viewport.maxY && rect.height <= viewport.height { +// // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. +// newContentOffset.y += rect.maxY - viewport.maxY +// } else if rect.maxY > viewport.maxY { +// newContentOffset.y += rect.minY +// } +// let cappedXOffset = min(max(newContentOffset.x, minimumContentOffset.x), maximumContentOffset.x) +// let cappedYOffset = min(max(newContentOffset.y, minimumContentOffset.y), maximumContentOffset.y) +// return CGPoint(x: cappedXOffset, y: cappedYOffset) +// } +//} +// +//// MARK: - TextInputViewDelegate +//extension TextView: TextInputViewDelegate { +// func textInputViewWillBeginEditing(_ view: TextInputView) { +// guard isEditable else { +// return +// } +// isEditing = !isPerformingNonEditableTextInteraction +// // If a developer is programmatically calling becomeFirstresponder() then we might not have a selected range. +// // We set the selectedRange instead of the selectedTextRange to avoid invoking any delegates. +// if textInputView.selectedRange == nil && !isPerformingNonEditableTextInteraction { +// textInputView.selectedRange = NSRange(location: 0, length: 0) +// } +// // Ensure selection is laid out without animation. +// UIView.performWithoutAnimation { +// textInputView.layoutIfNeeded() +// } +// // The editable interaction must be installed early in the -becomeFirstResponder() call +// if !isPerformingNonEditableTextInteraction { +// installEditableInteraction() +// } +// } +// +// func textInputViewDidBeginEditing(_ view: TextInputView) { +// if !isPerformingNonEditableTextInteraction { +// editorDelegate?.textViewDidBeginEditing(self) +// } +// } +// +// func textInputViewDidCancelBeginEditing(_ view: TextInputView) { +// isEditing = false +// installNonEditableInteraction() +// } +// +// func textInputViewDidEndEditing(_ view: TextInputView) { +// isEditing = false +// installNonEditableInteraction() +// editorDelegate?.textViewDidEndEditing(self) +// } +// +// func textInputViewDidChange(_ view: TextInputView) { +// if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { +// scrollLocationToVisible(newRange.location) +// } +// editorDelegate?.textViewDidChange(self) +// } +// +// func textInputViewDidChangeSelection(_ view: TextInputView) { +// UIMenuController.shared.hideMenu(from: self) +// highlightNavigationController.selectedRange = view.selectedRange +// if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { +// scrollLocationToVisible(newRange.location) +// } +// editorDelegate?.textViewDidChangeSelection(self) +// } +// +// func textInputViewDidInvalidateContentSize(_ view: TextInputView) { +// if contentSize != view.contentSize { +// hasPendingContentSizeUpdate = true +// handleContentSizeUpdateIfNeeded() +// } +// } +// +// func textInputView(_ view: TextInputView, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) { +// let isScrolling = isDragging || isDecelerating +// if contentOffsetAdjustment != .zero && isScrolling { +// contentOffset = CGPoint(x: contentOffset.x + contentOffsetAdjustment.x, y: contentOffset.y + contentOffsetAdjustment.y) +// } +// } +// +// func textInputView(_ view: TextInputView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { +// if textInputView.isRestoringPreviouslyDeletedText { +// // UIKit is inserting text to combine characters, for example to combine two Korean characters into one, and we do not want to interfere with that. +// return editorDelegate?.textView(self, shouldChangeTextIn: range, replacementText: text) ?? true +// } else if let characterPair = characterPairs.first(where: { $0.trailing == text }), +// skipInsertingTrailingComponent(of: characterPair, in: range) { +// return false +// } else if let characterPair = characterPairs.first(where: { $0.leading == text }), insertLeadingComponent(of: characterPair, in: range) { +// return false +// } else { +// return editorDelegate?.textView(self, shouldChangeTextIn: range, replacementText: text) ?? true +// } +// } +// +// func textInputViewDidChangeGutterWidth(_ view: TextInputView) { +// editorDelegate?.textViewDidChangeGutterWidth(self) +// } +// +// func textInputViewDidBeginFloatingCursor(_ view: TextInputView) { +// editorDelegate?.textViewDidBeginFloatingCursor(self) +// } +// +// func textInputViewDidEndFloatingCursor(_ view: TextInputView) { +// editorDelegate?.textViewDidEndFloatingCursor(self) +// } +// +// func textInputViewDidUpdateMarkedRange(_ view: TextInputView) { +// // There seems to be a bug in UITextInput (or UITextInteraction?) where updating the markedTextRange of a UITextInput +// // will cause the caret to disappear. Removing the editable text interaction and adding it back will work around this issue. +// DispatchQueue.main.async { +// if !view.viewHierarchyContainsCaret && self.editableTextInteraction.view != nil { +// view.removeInteraction(self.editableTextInteraction) +// view.addInteraction(self.editableTextInteraction) +// } +// } +// } +// +// func textInputView(_ view: TextInputView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { +// return editorDelegate?.textView(self, canReplaceTextIn: highlightedRange) ?? false +// } +// +// func textInputView(_ view: TextInputView, replaceTextIn highlightedRange: HighlightedRange) { +// editorDelegate?.textView(self, replaceTextIn: highlightedRange) +// } +//} +// +//// MARK: - HighlightNavigationControllerDelegate +//extension TextView: HighlightNavigationControllerDelegate { +// func highlightNavigationController(_ controller: HighlightNavigationController, +// shouldNavigateTo highlightNavigationRange: HighlightNavigationRange) { +// let range = highlightNavigationRange.range +// scrollRangeToVisible(range) +// textInputView.selectedTextRange = IndexedRange(range) +// _ = textInputView.becomeFirstResponder() +// textInputView.presentEditMenuForText(in: range) +// switch highlightNavigationRange.loopMode { +// case .previousGoesToLast: +// editorDelegate?.textViewDidLoopToLastHighlightedRange(self) +// case .nextGoesToFirst: +// editorDelegate?.textViewDidLoopToFirstHighlightedRange(self) +// case .disabled: +// break +// } +// } +//} +// +//// MARK: - SearchControllerDelegate +//extension TextView: SearchControllerDelegate { +// func searchController(_ searchController: SearchController, linePositionAt location: Int) -> LinePosition? { +// return textInputView.lineManager.linePosition(at: location) +// } +//} +// +//// MARK: - UIGestureRecognizerDelegate +//extension TextView: UIGestureRecognizerDelegate { +// override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { +// if gestureRecognizer === tapGestureRecognizer { +// return !isEditing && !isDragging && !isDecelerating && delegateAllowsEditingToBegin +// } else { +// return super.gestureRecognizerShouldBegin(gestureRecognizer) +// } +// } +// +// public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, +// shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { +// if let klass = NSClassFromString("UITextRangeAdjustmentGestureRecognizer") { +// if !textRangeAdjustmentGestureRecognizers.contains(otherGestureRecognizer) && otherGestureRecognizer.isKind(of: klass) { +// otherGestureRecognizer.addTarget(self, action: #selector(handleTextRangeAdjustmentPan(_:))) +// textRangeAdjustmentGestureRecognizers.insert(otherGestureRecognizer) +// } +// } +// return gestureRecognizer !== panGestureRecognizer +// } +//} +// +//// MARK: - KeyboardObserverDelegate +//extension TextView: KeyboardObserverDelegate { +// func keyboardObserver(_ keyboardObserver: KeyboardObserver, +// keyboardWillShowWithHeight keyboardHeight: CGFloat, +// animation: KeyboardObserver.Animation?) { +// if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { +// scrollRangeToVisible(newRange) +// } +// } +//} +// +//// MARK: - UITextInteractionDelegate +//extension TextView: UITextInteractionDelegate { +// public func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { +// if interaction.textInteractionMode == .editable { +// return isEditable +// } else if interaction.textInteractionMode == .nonEditable { +// // The private UITextLoupeInteraction and UITextNonEditableInteractionclass will end up in this case. The latter is likely created from UITextInteraction(for: .nonEditable) but we want to disable both when selection is disabled. +// return isSelectable +// } else { +// return true +// } +// } +// +// public func interactionWillBegin(_ interaction: UITextInteraction) { +// if interaction.textInteractionMode == .nonEditable { +// // When long-pressing our instance of UITextInput, the UITextInteraction will make the text input first responder. +// // In this case the user wants to select text in the text view but not start editing, so we set a flag that tells us +// // that we should not install editable text interaction in this case. +// isPerformingNonEditableTextInteraction = true +// } +// } +// +// public func interactionDidEnd(_ interaction: UITextInteraction) { +// if interaction.textInteractionMode == .nonEditable { +// isPerformingNonEditableTextInteraction = false +// } +// } +//} diff --git a/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift b/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift index 1d4aaeef4..ba9fc9308 100644 --- a/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift +++ b/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift @@ -1,6 +1,6 @@ -import UIKit +import Foundation -final class GutterBackgroundView: UIView { +final class GutterBackgroundView: MultiPlatformView { var hairlineWidth: CGFloat = 1 { didSet { if hairlineWidth != oldValue { @@ -8,7 +8,7 @@ final class GutterBackgroundView: UIView { } } } - var hairlineColor: UIColor? { + var hairlineColor: MultiPlatformColor? { get { return hairlineView.backgroundColor } @@ -17,7 +17,7 @@ final class GutterBackgroundView: UIView { } } - private let hairlineView = UIView() + private let hairlineView = MultiPlatformView() override init(frame: CGRect = .zero) { super.init(frame: .zero) @@ -28,8 +28,21 @@ final class GutterBackgroundView: UIView { fatalError("init(coder:) has not been implemented") } + #if os(iOS) override func layoutSubviews() { super.layoutSubviews() + _layoutSubviews() + } + #else + override func resizeSubviews(withOldSize oldSize: NSSize) { + super.resizeSubviews(withOldSize: oldSize) + _layoutSubviews() + } + #endif +} + +private extension GutterBackgroundView { + private func _layoutSubviews() { hairlineView.frame = CGRect(x: bounds.width - hairlineWidth, y: 0, width: hairlineWidth, height: bounds.height) } } diff --git a/Sources/Runestone/TextView/Gutter/GutterWidthService.swift b/Sources/Runestone/TextView/Gutter/GutterWidthService.swift index 5915a66b6..354fc2b28 100644 --- a/Sources/Runestone/TextView/Gutter/GutterWidthService.swift +++ b/Sources/Runestone/TextView/Gutter/GutterWidthService.swift @@ -1,5 +1,6 @@ import Combine -import UIKit +import CoreGraphics +import Foundation final class GutterWidthService { var lineManager: LineManager { @@ -9,7 +10,7 @@ final class GutterWidthService { } } } - var font = UIFont.monospacedSystemFont(ofSize: 14, weight: .regular) { + var font = MultiPlatformFont.monospacedSystemFont(ofSize: 14, weight: .regular) { didSet { if font != oldValue { _lineNumberWidth = nil @@ -58,7 +59,7 @@ final class GutterWidthService { private var _lineNumberWidth: CGFloat? private var previousLineCount = 0 - private var previousFont: UIFont? + private var previousFont: MultiPlatformFont? private var previouslySentGutterWidth: CGFloat? init(lineManager: LineManager) { diff --git a/Sources/Runestone/TextView/Gutter/LineNumberView.swift b/Sources/Runestone/TextView/Gutter/LineNumberView.swift index cdcf15dce..89800372e 100644 --- a/Sources/Runestone/TextView/Gutter/LineNumberView.swift +++ b/Sources/Runestone/TextView/Gutter/LineNumberView.swift @@ -1,49 +1,54 @@ +#if os(macOS) +import AppKit +#endif +#if os(iOS) import UIKit +#endif -final class LineNumberView: UIView, ReusableView { - var textColor: UIColor { - get { - return titleLabel.textColor - } - set { - titleLabel.textColor = newValue +final class LineNumberView: MultiPlatformView, ReusableView { + var textColor: MultiPlatformColor = .black { + didSet { + if textColor != oldValue { + setNeedsDisplay() + } } } - var font: UIFont { - get { - return titleLabel.font - } - set { - titleLabel.font = newValue + var font: MultiPlatformFont = .systemFont(ofSize: 14) { + didSet { + if font != oldValue { + setNeedsDisplay() + } } } var text: String? { - get { - return titleLabel.text - } - set { - titleLabel.text = newValue + didSet { + if text != oldValue { + setNeedsDisplay() + } } } - private let titleLabel: UILabel = { - let this = UILabel() - this.textAlignment = .right - return this - }() - - override init(frame: CGRect = .zero) { - super.init(frame: frame) - addSubview(titleLabel) + #if os(iOS) + override func draw(_ rect: CGRect) { + super.draw(rect) + _drawRect() } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") + #else + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + _drawRect() } + #endif +} - override func layoutSubviews() { - super.layoutSubviews() - let size = titleLabel.intrinsicContentSize - titleLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: size.height) +private extension LineNumberView { + private func _drawRect() { + guard let text = text as? NSString else { + return + } + let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor] + let size = text.size(withAttributes: attributes) + let offset = CGPoint(x: bounds.width - size.width, y: (bounds.height - size.height) / 2) + text.draw(at: offset) } } diff --git a/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift b/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift index 52b992e45..60e501abe 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift @@ -1,5 +1,4 @@ import Foundation -import UIKit protocol HighlightNavigationControllerDelegate: AnyObject { func highlightNavigationController( diff --git a/Sources/Runestone/TextView/Highlight/HighlightedRange.swift b/Sources/Runestone/TextView/Highlight/HighlightedRange.swift index d39fcd47a..f1c99b22c 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightedRange.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightedRange.swift @@ -1,4 +1,4 @@ -import UIKit +import Foundation /// Range of text to highlight. public final class HighlightedRange { @@ -7,7 +7,7 @@ public final class HighlightedRange { /// Range in the text to highlight. public let range: NSRange /// Color to highlight the text with. - public let color: UIColor + public let color: MultiPlatformColor /// Corner radius of the highlight. public let cornerRadius: CGFloat @@ -17,7 +17,7 @@ public final class HighlightedRange { /// - range: Range in the text to highlight. /// - color: Color to highlight the text with. /// - cornerRadius: Corner radius of the highlight. A value of zero or less means no corner radius. Defaults to 0. - public init(id: String = UUID().uuidString, range: NSRange, color: UIColor, cornerRadius: CGFloat = 0) { + public init(id: String = UUID().uuidString, range: NSRange, color: MultiPlatformColor, cornerRadius: CGFloat = 0) { self.id = id self.range = range self.color = color diff --git a/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift b/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift index c5553c962..b8e262c57 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift @@ -1,24 +1,13 @@ -import UIKit +import Foundation final class HighlightedRangeFragment: Equatable { let range: NSRange let containsStart: Bool let containsEnd: Bool - let color: UIColor + let color: MultiPlatformColor let cornerRadius: CGFloat - var roundedCorners: UIRectCorner { - if containsStart && containsEnd { - return .allCorners - } else if containsStart { - return [.topLeft, .bottomLeft] - } else if containsEnd { - return [.topRight, .bottomRight] - } else { - return [] - } - } - init(range: NSRange, containsStart: Bool, containsEnd: Bool, color: UIColor, cornerRadius: CGFloat) { + init(range: NSRange, containsStart: Bool, containsEnd: Bool, color: MultiPlatformColor, cornerRadius: CGFloat) { self.range = range self.containsStart = containsStart self.containsEnd = containsEnd diff --git a/Sources/Runestone/TextView/Indent/IndentController.swift b/Sources/Runestone/TextView/Indent/IndentController.swift index e4c0fba89..fcec5e00b 100644 --- a/Sources/Runestone/TextView/Indent/IndentController.swift +++ b/Sources/Runestone/TextView/Indent/IndentController.swift @@ -1,5 +1,4 @@ import Foundation -import UIKit protocol IndentControllerDelegate: AnyObject { func indentController(_ controller: IndentController, shouldInsert text: String, in range: NSRange) @@ -8,11 +7,15 @@ protocol IndentControllerDelegate: AnyObject { } final class IndentController { + #if os(macOS) + private typealias NSStringDrawingOptions = NSString.DrawingOptions + #endif + weak var delegate: IndentControllerDelegate? var stringView: StringView var lineManager: LineManager var languageMode: InternalLanguageMode - var indentFont: UIFont { + var indentFont: MultiPlatformFont { didSet { if indentFont != oldValue { _tabWidth = nil @@ -46,7 +49,7 @@ final class IndentController { private var _tabWidth: CGFloat? - init(stringView: StringView, lineManager: LineManager, languageMode: InternalLanguageMode, indentStrategy: IndentStrategy, indentFont: UIFont) { + init(stringView: StringView, lineManager: LineManager, languageMode: InternalLanguageMode, indentStrategy: IndentStrategy, indentFont: MultiPlatformFont) { self.stringView = stringView self.lineManager = lineManager self.languageMode = languageMode diff --git a/Sources/Runestone/TextView/InvisibleCharacters/InvisibleCharacterConfiguration.swift b/Sources/Runestone/TextView/InvisibleCharacters/InvisibleCharacterConfiguration.swift index a2c2caf3f..cda59260b 100644 --- a/Sources/Runestone/TextView/InvisibleCharacters/InvisibleCharacterConfiguration.swift +++ b/Sources/Runestone/TextView/InvisibleCharacters/InvisibleCharacterConfiguration.swift @@ -1,7 +1,13 @@ +#if os(macOS) +import AppKit +#endif +import CoreGraphics +#if os(iOS) import UIKit +#endif final class InvisibleCharacterConfiguration { - var font: UIFont = .systemFont(ofSize: 12) { + var font: MultiPlatformFont = .systemFont(ofSize: 12) { didSet { if font != oldValue { _lineBreakSymbolSize = nil @@ -9,7 +15,7 @@ final class InvisibleCharacterConfiguration { } } } - var textColor: UIColor = .label + var textColor: MultiPlatformColor = .label var showTabs = false var showSpaces = false var showNonBreakingSpaces = false diff --git a/Sources/Runestone/TextView/LineController/LineController.swift b/Sources/Runestone/TextView/LineController/LineController.swift index eb518293e..c3312f089 100644 --- a/Sources/Runestone/TextView/LineController/LineController.swift +++ b/Sources/Runestone/TextView/LineController/LineController.swift @@ -1,7 +1,13 @@ // swiftlint:disable file_length +#if os(macOS) +import AppKit +#endif import CoreGraphics import CoreText +import Foundation +#if os(iOS) import UIKit +#endif typealias LineFragmentTree = RedBlackTree diff --git a/Sources/Runestone/TextView/LineController/LineFragmentController.swift b/Sources/Runestone/TextView/LineController/LineFragmentController.swift index c94d5fe37..4aadbe5cd 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentController.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentController.swift @@ -1,4 +1,4 @@ -import UIKit +import Foundation protocol LineFragmentControllerDelegate: AnyObject { func string(in controller: LineFragmentController) -> String? @@ -32,7 +32,7 @@ final class LineFragmentController { } } } - var markedTextBackgroundColor: UIColor { + var markedTextBackgroundColor: MultiPlatformColor { get { return renderer.markedTextBackgroundColor } diff --git a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift index 984403204..27f41eb6a 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift @@ -1,5 +1,5 @@ import CoreText -import UIKit +import Foundation protocol LineFragmentRendererDelegate: AnyObject { func string(in lineFragmentRenderer: LineFragmentRenderer) -> String? @@ -15,7 +15,7 @@ final class LineFragmentRenderer { var lineFragment: LineFragment let invisibleCharacterConfiguration: InvisibleCharacterConfiguration var markedRange: NSRange? - var markedTextBackgroundColor: UIColor = .systemFill + var markedTextBackgroundColor: MultiPlatformColor = .systemFill var markedTextBackgroundCornerRadius: CGFloat = 0 var highlightedRangeFragments: [HighlightedRangeFragment] = [] @@ -54,16 +54,10 @@ private extension LineFragmentRenderer { endX = CTLineGetOffsetForStringIndex(lineFragment.line, highlightedRange.range.upperBound, nil) } let rect = CGRect(x: startX, y: 0, width: endX - startX, height: lineFragment.scaledSize.height) - let roundedCorners = highlightedRange.roundedCorners + let path = highlightedRange.path(in: rect) context.setFillColor(highlightedRange.color.cgColor) - if !roundedCorners.isEmpty && highlightedRange.cornerRadius > 0 { - let cornerRadii = CGSize(width: highlightedRange.cornerRadius, height: highlightedRange.cornerRadius) - let bezierPath = UIBezierPath(roundedRect: rect, byRoundingCorners: roundedCorners, cornerRadii: cornerRadii) - context.addPath(bezierPath.cgPath) - context.fillPath() - } else { - context.fill(rect) - } + context.addPath(path) + context.fillPath() } context.restoreGState() } @@ -159,3 +153,24 @@ private extension LineFragmentRenderer { return string == Symbol.Character.lineFeed || string == Symbol.Character.carriageReturn || string == Symbol.Character.carriageReturnLineFeed } } + +private extension HighlightedRangeFragment { + func path(in rect: CGRect) -> CGPath { + guard containsStart || containsEnd else { + return CGPath(rect: rect, transform: nil) + } + var path = CGPath(roundedRect: rect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + // Union start and end paths if we should not round the edges. + if !containsStart { + let startRect = CGRect(x: 0, y: 0, width: cornerRadius, height: rect.height) + let startPath = CGPath(rect: startRect, transform: nil) + path = path.union(startPath) + } + if !containsStart { + let startRect = CGRect(x: 0, y: 0, width: cornerRadius, height: rect.height) + let startPath = CGPath(rect: startRect, transform: nil) + path = path.union(startPath) + } + return path + } +} diff --git a/Sources/Runestone/TextView/PageGuide/PageGuideController.swift b/Sources/Runestone/TextView/PageGuide/PageGuideController.swift index 3ef7a6cd0..d1bd096e9 100644 --- a/Sources/Runestone/TextView/PageGuide/PageGuideController.swift +++ b/Sources/Runestone/TextView/PageGuide/PageGuideController.swift @@ -1,8 +1,17 @@ +#if os(macOS) +import AppKit +#endif +#if os(iOS) import UIKit +#endif final class PageGuideController { + #if os(macOS) + private typealias NSStringDrawingOptions = NSString.DrawingOptions + #endif + let guideView = PageGuideView() - var font: UIFont = .systemFont(ofSize: 14) { + var font: MultiPlatformFont = .systemFont(ofSize: 14) { didSet { if font != oldValue { _columnOffset = nil diff --git a/Sources/Runestone/TextView/PageGuide/PageGuideView.swift b/Sources/Runestone/TextView/PageGuide/PageGuideView.swift index a11e93ef3..019a84bd2 100644 --- a/Sources/Runestone/TextView/PageGuide/PageGuideView.swift +++ b/Sources/Runestone/TextView/PageGuide/PageGuideView.swift @@ -1,14 +1,19 @@ +#if os(macOS) +import AppKit +#endif +#if os(iOS) import UIKit +#endif -final class PageGuideView: UIView { - var hairlineWidth: CGFloat = 1 / UIScreen.main.scale { +final class PageGuideView: MultiPlatformView { + var hairlineWidth: CGFloat { didSet { if hairlineWidth != oldValue { setNeedsLayout() } } } - var hairlineColor: UIColor? { + var hairlineColor: MultiPlatformColor? { get { return hairlineView.backgroundColor } @@ -17,12 +22,19 @@ final class PageGuideView: UIView { } } - private let hairlineView = UIView() + private let hairlineView = MultiPlatformView() override init(frame: CGRect) { + #if os(iOS) + hairlineWidth = 1 / UIScreen.main.scale + #else + hairlineWidth = 1 / NSScreen.main!.backingScaleFactor + #endif super.init(frame: frame) + #if os(iOS) isUserInteractionEnabled = false hairlineView.isUserInteractionEnabled = false + #endif addSubview(hairlineView) } @@ -30,8 +42,21 @@ final class PageGuideView: UIView { fatalError("init(coder:) has not been implemented") } + #if os(iOS) override func layoutSubviews() { super.layoutSubviews() + _layoutSubviews() + } + #else + override func resizeSubviews(withOldSize oldSize: NSSize) { + super.resizeSubviews(withOldSize: oldSize) + _layoutSubviews() + } + #endif +} + +private extension PageGuideView { + private func _layoutSubviews() { hairlineView.frame = CGRect(x: 0, y: 0, width: hairlineWidth, height: bounds.height) } } diff --git a/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift similarity index 99% rename from Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift rename to Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift index 72495876a..521ec89b6 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift @@ -1,3 +1,4 @@ +#if os(iOS) import UIKit final class UITextSearchingHelper: NSObject { @@ -200,3 +201,4 @@ private extension SearchQuery.MatchMethod { } } #endif +#endif diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift index c6333b923..c5c4ff6f8 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift @@ -1,16 +1,21 @@ +#if os(macOS) +import AppKit +#endif +#if os(iOS) import UIKit +#endif final class TreeSitterSyntaxHighlightToken { let range: NSRange - let textColor: UIColor? + let textColor: MultiPlatformColor? let shadow: NSShadow? - let font: UIFont? + let font: MultiPlatformFont? let fontTraits: FontTraits var isEmpty: Bool { return range.length == 0 || (textColor == nil && font == nil && shadow == nil) } - init(range: NSRange, textColor: UIColor?, shadow: NSShadow?, font: UIFont?, fontTraits: FontTraits) { + init(range: NSRange, textColor: MultiPlatformColor?, shadow: NSShadow?, font: MultiPlatformFont?, fontTraits: FontTraits) { self.range = range self.textColor = textColor self.shadow = shadow diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift index 8885b9a0b..8951522d3 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift @@ -1,4 +1,4 @@ -import UIKit +import Foundation enum TreeSitterSyntaxHighlighterError: LocalizedError { case cancelled @@ -97,16 +97,24 @@ private extension TreeSitterSyntaxHighlighter { if token.fontTraits.contains(.italic) { attributedString.addAttribute(.isItalic, value: true, range: token.range) } - var symbolicTraits: UIFontDescriptor.SymbolicTraits = [] + var symbolicTraits: MultiPlatformFontDescriptor.SymbolicTraits = [] if let isBold = attributedString.attribute(.isBold, at: token.range.location, effectiveRange: nil) as? Bool, isBold { + #if os(iOS) symbolicTraits.insert(.traitBold) + #else + symbolicTraits.insert(.bold) + #endif } if let isItalic = attributedString.attribute(.isItalic, at: token.range.location, effectiveRange: nil) as? Bool, isItalic { + #if os(iOS) symbolicTraits.insert(.traitItalic) + #else + symbolicTraits.insert(.italic) + #endif } - let currentFont = attributedString.attribute(.font, at: token.range.location, effectiveRange: nil) as? UIFont + let currentFont = attributedString.attribute(.font, at: token.range.location, effectiveRange: nil) as? MultiPlatformFont let baseFont = token.font ?? theme.font - let newFont: UIFont + let newFont: MultiPlatformFont if !symbolicTraits.isEmpty { newFont = baseFont.withSymbolicTraits(symbolicTraits) ?? baseFont } else { @@ -154,12 +162,17 @@ private extension TreeSitterSyntaxHighlighter { } } -private extension UIFont { - func withSymbolicTraits(_ symbolicTraits: UIFontDescriptor.SymbolicTraits) -> UIFont? { +private extension MultiPlatformFont { + func withSymbolicTraits(_ symbolicTraits: MultiPlatformFontDescriptor.SymbolicTraits) -> MultiPlatformFont? { + #if os(iOS) if let newFontDescriptor = fontDescriptor.withSymbolicTraits(symbolicTraits) { - return UIFont(descriptor: newFontDescriptor, size: pointSize) + return MultiPlatformFont(descriptor: newFontDescriptor, size: pointSize) } else { return nil } + #else + let newFontDescriptor = fontDescriptor.withSymbolicTraits(symbolicTraits) + return MultiPlatformFont(descriptor: newFontDescriptor, size: pointSize) + #endif } } diff --git a/Sources/Runestone/TextView/TextSelection/CaretRectService.swift b/Sources/Runestone/TextView/TextSelection/CaretRectService.swift index 5fac7727e..0a24c75ea 100644 --- a/Sources/Runestone/TextView/TextSelection/CaretRectService.swift +++ b/Sources/Runestone/TextView/TextSelection/CaretRectService.swift @@ -1,9 +1,9 @@ -import UIKit +import CoreGraphics final class CaretRectService { var stringView: StringView var lineManager: LineManager - var textContainerInset: UIEdgeInsets = .zero + var textContainerInset: MultiPlatformEdgeInsets = .zero var showLineNumbers = false private let lineControllerStorage: LineControllerStorage diff --git a/Sources/Runestone/TextView/TextSelection/SelectionRectService.swift b/Sources/Runestone/TextView/TextSelection/SelectionRectService.swift index 909150e64..3d1ba731c 100644 --- a/Sources/Runestone/TextView/TextSelection/SelectionRectService.swift +++ b/Sources/Runestone/TextView/TextSelection/SelectionRectService.swift @@ -1,8 +1,9 @@ -import UIKit +import CoreGraphics +import Foundation final class SelectionRectService { var lineManager: LineManager - var textContainerInset: UIEdgeInsets = .zero + var textContainerInset: MultiPlatformEdgeInsets = .zero var lineHeightMultiplier: CGFloat = 1 private let contentSizeService: ContentSizeService diff --git a/Sources/Runestone/TextView/TextSelection/TextSelectionRect.swift b/Sources/Runestone/TextView/TextSelection/iOS/TextSelectionRect.swift similarity index 60% rename from Sources/Runestone/TextView/TextSelection/TextSelectionRect.swift rename to Sources/Runestone/TextView/TextSelection/iOS/TextSelectionRect.swift index 4187fdef9..aac4aa623 100644 --- a/Sources/Runestone/TextView/TextSelection/TextSelectionRect.swift +++ b/Sources/Runestone/TextView/TextSelection/iOS/TextSelectionRect.swift @@ -1,5 +1,12 @@ +#if os(macOS) +import AppKit +#endif +import CoreGraphics +#if os(iOS) import UIKit +#endif +#if os(iOS) final class TextSelectionRect: UITextSelectionRect { override var rect: CGRect { return _rect @@ -31,3 +38,20 @@ final class TextSelectionRect: UITextSelectionRect { _isVertical = isVertical } } +#else +final class TextSelectionRect { + let rect: CGRect + let writingDirection: NSWritingDirection + let containsStart: Bool + let containsEnd: Bool + let isVertical: Bool + + init(rect: CGRect, writingDirection: NSWritingDirection, containsStart: Bool, containsEnd: Bool, isVertical: Bool = false) { + self.rect = rect + self.writingDirection = writingDirection + self.containsStart = containsStart + self.containsEnd = containsEnd + self.isVertical = isVertical + } +} +#endif diff --git a/Tests/RunestoneTests/TextInputStringTokenizerTests.swift b/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift similarity index 99% rename from Tests/RunestoneTests/TextInputStringTokenizerTests.swift rename to Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift index c4276959e..071f05cdf 100644 --- a/Tests/RunestoneTests/TextInputStringTokenizerTests.swift +++ b/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift @@ -1,3 +1,4 @@ +#if os(iOS) // swiftlint:disable force_cast @testable import Runestone import XCTest @@ -304,3 +305,4 @@ extension TextInputStringTokenizerTests: LineControllerDelegate { func lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ lineController: LineController) {} } +#endif From b334434ae8f8fb3a0a4d09e73c69a900117c114b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 27 Jan 2023 12:52:49 +0100 Subject: [PATCH 002/232] Fixes compile errors when targeting iOS --- .../Runestone/Library/ViewReuseQueue.swift | 4 ++ .../TextView/Appearance/DefaultTheme.swift | 4 ++ .../TextView/Core/LayoutManager.swift | 3 ++ .../TextView/Indent/IndentController.swift | 3 ++ .../LineController/LineFragmentRenderer.swift | 39 ++++++++----------- 5 files changed, 30 insertions(+), 23 deletions(-) diff --git a/Sources/Runestone/Library/ViewReuseQueue.swift b/Sources/Runestone/Library/ViewReuseQueue.swift index e305273df..82bc3afba 100644 --- a/Sources/Runestone/Library/ViewReuseQueue.swift +++ b/Sources/Runestone/Library/ViewReuseQueue.swift @@ -1,3 +1,7 @@ +#if os(iOS) +import UIKit +#endif + protocol ReusableView { func prepareForReuse() } diff --git a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift index f341a66b5..e52c10dea 100644 --- a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift +++ b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift @@ -1,3 +1,7 @@ +#if os(iOS) +import UIKit +#endif + /// Default theme used by Runestone when no other theme has been set. public final class DefaultTheme: Runestone.Theme { public let font: MultiPlatformFont = .monospacedSystemFont(ofSize: 14, weight: .regular) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index e17ac36ba..d3e6d9023 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -1,6 +1,9 @@ import CoreGraphics import Foundation import QuartzCore +#if os(iOS) +import UIKit +#endif // swiftlint:disable file_length protocol LayoutManagerDelegate: AnyObject { diff --git a/Sources/Runestone/TextView/Indent/IndentController.swift b/Sources/Runestone/TextView/Indent/IndentController.swift index fcec5e00b..24fc86c54 100644 --- a/Sources/Runestone/TextView/Indent/IndentController.swift +++ b/Sources/Runestone/TextView/Indent/IndentController.swift @@ -1,4 +1,7 @@ import Foundation +#if os(iOS) +import UIKit +#endif protocol IndentControllerDelegate: AnyObject { func indentController(_ controller: IndentController, shouldInsert text: String, in range: NSRange) diff --git a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift index 27f41eb6a..fe81bcb1b 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift @@ -53,11 +53,25 @@ private extension LineFragmentRenderer { } else { endX = CTLineGetOffsetForStringIndex(lineFragment.line, highlightedRange.range.upperBound, nil) } + let cornerRadius = highlightedRange.cornerRadius let rect = CGRect(x: startX, y: 0, width: endX - startX, height: lineFragment.scaledSize.height) - let path = highlightedRange.path(in: rect) + let roundedPath = CGPath(roundedRect: rect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) context.setFillColor(highlightedRange.color.cgColor) - context.addPath(path) + context.addPath(roundedPath) context.fillPath() + // Draw non-rounded edges if needed. + if !highlightedRange.containsStart { + let startRect = CGRect(x: 0, y: 0, width: cornerRadius, height: rect.height) + let startPath = CGPath(rect: startRect, transform: nil) + context.addPath(startPath) + context.fillPath() + } + if !highlightedRange.containsEnd { + let endRect = CGRect(x: 0, y: 0, width: rect.width - cornerRadius, height: rect.height) + let endPath = CGPath(rect: endRect, transform: nil) + context.addPath(endPath) + context.fillPath() + } } context.restoreGState() } @@ -153,24 +167,3 @@ private extension LineFragmentRenderer { return string == Symbol.Character.lineFeed || string == Symbol.Character.carriageReturn || string == Symbol.Character.carriageReturnLineFeed } } - -private extension HighlightedRangeFragment { - func path(in rect: CGRect) -> CGPath { - guard containsStart || containsEnd else { - return CGPath(rect: rect, transform: nil) - } - var path = CGPath(roundedRect: rect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) - // Union start and end paths if we should not round the edges. - if !containsStart { - let startRect = CGRect(x: 0, y: 0, width: cornerRadius, height: rect.height) - let startPath = CGPath(rect: startRect, transform: nil) - path = path.union(startPath) - } - if !containsStart { - let startRect = CGRect(x: 0, y: 0, width: cornerRadius, height: rect.height) - let startPath = CGPath(rect: startRect, transform: nil) - path = path.union(startPath) - } - return path - } -} From d48ce155ae243a840887d5c1f320200345e1385c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 27 Jan 2023 13:02:05 +0100 Subject: [PATCH 003/232] Correctly draws line numbers --- .../TextView/Gutter/LineNumberView.swift | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/Sources/Runestone/TextView/Gutter/LineNumberView.swift b/Sources/Runestone/TextView/Gutter/LineNumberView.swift index 89800372e..5144bc2e9 100644 --- a/Sources/Runestone/TextView/Gutter/LineNumberView.swift +++ b/Sources/Runestone/TextView/Gutter/LineNumberView.swift @@ -28,6 +28,19 @@ final class LineNumberView: MultiPlatformView, ReusableView { } } + init() { + super.init(frame: .zero) + #if os(iOS) + isOpaque = false + #else + layer?.isOpaque = false + #endif + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + #if os(iOS) override func draw(_ rect: CGRect) { super.draw(rect) @@ -43,12 +56,13 @@ final class LineNumberView: MultiPlatformView, ReusableView { private extension LineNumberView { private func _drawRect() { - guard let text = text as? NSString else { + guard let text else { return } let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor] - let size = text.size(withAttributes: attributes) + let attributedString = NSAttributedString(string: text, attributes: attributes) + let size = attributedString.size() let offset = CGPoint(x: bounds.width - size.width, y: (bounds.height - size.height) / 2) - text.draw(at: offset) + attributedString.draw(at: offset) } } From 75fc712d2581223b8a68d21115bf5b8404439894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 27 Jan 2023 13:06:03 +0100 Subject: [PATCH 004/232] Renames Example to iOSExample --- Example/Example.xcodeproj/project.pbxproj | 34 +++++++++--------- .../{Example.xcscheme => iOSExample.xcscheme} | 12 +++---- .../Application/AppDelegate.swift | 0 .../Application/SceneDelegate.swift | 0 .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/AppIcon-1024.png | Bin .../AppIcon.appiconset/AppIcon-120.png | Bin .../AppIcon.appiconset/AppIcon-152.png | Bin .../AppIcon.appiconset/AppIcon-167.png | Bin .../AppIcon.appiconset/AppIcon-180.png | Bin .../AppIcon.appiconset/AppIcon-20.png | Bin .../AppIcon.appiconset/AppIcon-29.png | Bin .../AppIcon.appiconset/AppIcon-40.png | Bin .../AppIcon.appiconset/AppIcon-58.png | Bin .../AppIcon.appiconset/AppIcon-60.png | Bin .../AppIcon.appiconset/AppIcon-76.png | Bin .../AppIcon.appiconset/AppIcon-80.png | Bin .../AppIcon.appiconset/AppIcon-87.png | Bin .../AppIcon.appiconset/Contents.json | 0 .../Assets.xcassets/Contents.json | 0 .../Base.lproj/LaunchScreen.storyboard | 0 Example/{Example => iOSExample}/Info.plist | 0 .../Library/BasicCharacterPair.swift | 0 .../Library/CodeSample.swift | 0 .../Library/ProcessInfo+Helpers.swift | 0 .../Library/TextView+Helpers.swift | 0 .../Library/ThemeSetting.swift | 0 .../Library/UserDefaults+Helpers.swift | 0 .../Main/KeyboardToolsView.swift | 0 .../Main/MainView.swift | 0 .../Main/MainViewController.swift | 0 .../ThemePicker/ThemePickerPreviewCell.swift | 0 .../ThemePickerViewController.swift | 0 .../iOSExample.entitlements} | 0 34 files changed, 23 insertions(+), 23 deletions(-) rename Example/Example.xcodeproj/xcshareddata/xcschemes/{Example.xcscheme => iOSExample.xcscheme} (90%) rename Example/{Example => iOSExample}/Application/AppDelegate.swift (100%) rename Example/{Example => iOSExample}/Application/SceneDelegate.swift (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png (100%) rename Example/{Example => iOSExample}/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename Example/{Example => iOSExample}/Assets.xcassets/Contents.json (100%) rename Example/{Example => iOSExample}/Base.lproj/LaunchScreen.storyboard (100%) rename Example/{Example => iOSExample}/Info.plist (100%) rename Example/{Example => iOSExample}/Library/BasicCharacterPair.swift (100%) rename Example/{Example => iOSExample}/Library/CodeSample.swift (100%) rename Example/{Example => iOSExample}/Library/ProcessInfo+Helpers.swift (100%) rename Example/{Example => iOSExample}/Library/TextView+Helpers.swift (100%) rename Example/{Example => iOSExample}/Library/ThemeSetting.swift (100%) rename Example/{Example => iOSExample}/Library/UserDefaults+Helpers.swift (100%) rename Example/{Example => iOSExample}/Main/KeyboardToolsView.swift (100%) rename Example/{Example => iOSExample}/Main/MainView.swift (100%) rename Example/{Example => iOSExample}/Main/MainViewController.swift (100%) rename Example/{Example => iOSExample}/ThemePicker/ThemePickerPreviewCell.swift (100%) rename Example/{Example => iOSExample}/ThemePicker/ThemePickerViewController.swift (100%) rename Example/{Example/Example.entitlements => iOSExample/iOSExample.entitlements} (100%) diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index e1b7454c1..033f49e64 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -32,7 +32,7 @@ /* Begin PBXFileReference section */ 7216EAC62829A16C001B6D39 /* Themes */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Themes; sourceTree = ""; }; - 7243F9BA282D73E9005AAABF /* Example.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Example.entitlements; sourceTree = ""; }; + 7243F9BA282D73E9005AAABF /* iOSExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iOSExample.entitlements; sourceTree = ""; }; 72AC54762826B1F00037ED21 /* Languages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Languages; sourceTree = ""; }; 72AC54772826B23D0037ED21 /* Runestone */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Runestone; path = ..; sourceTree = ""; }; 72D2718129126F190070FA88 /* ProcessInfo+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+Helpers.swift"; sourceTree = ""; }; @@ -44,7 +44,7 @@ AC85538427A84CF600F7916D /* TextView+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TextView+Helpers.swift"; sourceTree = ""; }; ACB08AD227A8113E00EB6819 /* ThemePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerViewController.swift; sourceTree = ""; }; ACB08AD427A81ADF00EB6819 /* ThemePickerPreviewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemePickerPreviewCell.swift; sourceTree = ""; }; - ACFDF4AD27983BAA00059A1B /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + ACFDF4AD27983BAA00059A1B /* iOSExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iOSExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; ACFDF4B027983BAA00059A1B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; ACFDF4B227983BAA00059A1B /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; ACFDF4B427983BAA00059A1B /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; @@ -126,7 +126,7 @@ isa = PBXGroup; children = ( 72AC54722826B0E40037ED21 /* Packages */, - ACFDF4AF27983BAA00059A1B /* Example */, + ACFDF4AF27983BAA00059A1B /* iOSExample */, ACFDF4AE27983BAA00059A1B /* Products */, ACFDF4C827983DA900059A1B /* Frameworks */, ); @@ -135,15 +135,15 @@ ACFDF4AE27983BAA00059A1B /* Products */ = { isa = PBXGroup; children = ( - ACFDF4AD27983BAA00059A1B /* Example.app */, + ACFDF4AD27983BAA00059A1B /* iOSExample.app */, ); name = Products; sourceTree = ""; }; - ACFDF4AF27983BAA00059A1B /* Example */ = { + ACFDF4AF27983BAA00059A1B /* iOSExample */ = { isa = PBXGroup; children = ( - 7243F9BA282D73E9005AAABF /* Example.entitlements */, + 7243F9BA282D73E9005AAABF /* iOSExample.entitlements */, ACFDF4BE27983BAB00059A1B /* Info.plist */, ACFDF4B927983BAB00059A1B /* Assets.xcassets */, ACFDF4BB27983BAB00059A1B /* LaunchScreen.storyboard */, @@ -152,7 +152,7 @@ AC832D592798C73300EC6832 /* Main */, ACB08ACF27A8112600EB6819 /* ThemePicker */, ); - path = Example; + path = iOSExample; sourceTree = ""; }; ACFDF4C827983DA900059A1B /* Frameworks */ = { @@ -165,9 +165,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - ACFDF4AC27983BAA00059A1B /* Example */ = { + ACFDF4AC27983BAA00059A1B /* iOSExample */ = { isa = PBXNativeTarget; - buildConfigurationList = ACFDF4C127983BAB00059A1B /* Build configuration list for PBXNativeTarget "Example" */; + buildConfigurationList = ACFDF4C127983BAB00059A1B /* Build configuration list for PBXNativeTarget "iOSExample" */; buildPhases = ( ACFDF4A927983BAA00059A1B /* Sources */, ACFDF4AA27983BAA00059A1B /* Frameworks */, @@ -178,7 +178,7 @@ ); dependencies = ( ); - name = Example; + name = iOSExample; packageProductDependencies = ( 72AC54802826B2A90037ED21 /* Runestone */, 7216EAC72829A3C6001B6D39 /* RunestoneJavaScriptLanguage */, @@ -188,7 +188,7 @@ 7216EACF2829A3C6001B6D39 /* RunestoneTomorrowTheme */, ); productName = Example; - productReference = ACFDF4AD27983BAA00059A1B /* Example.app */; + productReference = ACFDF4AD27983BAA00059A1B /* iOSExample.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -221,7 +221,7 @@ projectDirPath = ""; projectRoot = ""; targets = ( - ACFDF4AC27983BAA00059A1B /* Example */, + ACFDF4AC27983BAA00059A1B /* iOSExample */, ); }; /* End PBXProject section */ @@ -416,12 +416,12 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; + CODE_SIGN_ENTITLEMENTS = iOSExample/iOSExample.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 8NQFWJHC63; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Example/Info.plist; + INFOPLIST_FILE = iOSExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -446,12 +446,12 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Example/Example.entitlements; + CODE_SIGN_ENTITLEMENTS = iOSExample/iOSExample.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 8NQFWJHC63; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Example/Info.plist; + INFOPLIST_FILE = iOSExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -483,7 +483,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - ACFDF4C127983BAB00059A1B /* Build configuration list for PBXNativeTarget "Example" */ = { + ACFDF4C127983BAB00059A1B /* Build configuration list for PBXNativeTarget "iOSExample" */ = { isa = XCConfigurationList; buildConfigurations = ( ACFDF4C227983BAB00059A1B /* Debug */, diff --git a/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme b/Example/Example.xcodeproj/xcshareddata/xcschemes/iOSExample.xcscheme similarity index 90% rename from Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme rename to Example/Example.xcodeproj/xcshareddata/xcschemes/iOSExample.xcscheme index be31fd3c7..67859f5e0 100644 --- a/Example/Example.xcodeproj/xcshareddata/xcschemes/Example.xcscheme +++ b/Example/Example.xcodeproj/xcshareddata/xcschemes/iOSExample.xcscheme @@ -15,8 +15,8 @@ @@ -45,8 +45,8 @@ @@ -62,8 +62,8 @@ diff --git a/Example/Example/Application/AppDelegate.swift b/Example/iOSExample/Application/AppDelegate.swift similarity index 100% rename from Example/Example/Application/AppDelegate.swift rename to Example/iOSExample/Application/AppDelegate.swift diff --git a/Example/Example/Application/SceneDelegate.swift b/Example/iOSExample/Application/SceneDelegate.swift similarity index 100% rename from Example/Example/Application/SceneDelegate.swift rename to Example/iOSExample/Application/SceneDelegate.swift diff --git a/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/iOSExample/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json rename to Example/iOSExample/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-120.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-152.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-167.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-180.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-20.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-29.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-40.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-58.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-60.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-76.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-80.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/AppIcon-87.png diff --git a/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/iOSExample/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json rename to Example/iOSExample/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Example/Example/Assets.xcassets/Contents.json b/Example/iOSExample/Assets.xcassets/Contents.json similarity index 100% rename from Example/Example/Assets.xcassets/Contents.json rename to Example/iOSExample/Assets.xcassets/Contents.json diff --git a/Example/Example/Base.lproj/LaunchScreen.storyboard b/Example/iOSExample/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from Example/Example/Base.lproj/LaunchScreen.storyboard rename to Example/iOSExample/Base.lproj/LaunchScreen.storyboard diff --git a/Example/Example/Info.plist b/Example/iOSExample/Info.plist similarity index 100% rename from Example/Example/Info.plist rename to Example/iOSExample/Info.plist diff --git a/Example/Example/Library/BasicCharacterPair.swift b/Example/iOSExample/Library/BasicCharacterPair.swift similarity index 100% rename from Example/Example/Library/BasicCharacterPair.swift rename to Example/iOSExample/Library/BasicCharacterPair.swift diff --git a/Example/Example/Library/CodeSample.swift b/Example/iOSExample/Library/CodeSample.swift similarity index 100% rename from Example/Example/Library/CodeSample.swift rename to Example/iOSExample/Library/CodeSample.swift diff --git a/Example/Example/Library/ProcessInfo+Helpers.swift b/Example/iOSExample/Library/ProcessInfo+Helpers.swift similarity index 100% rename from Example/Example/Library/ProcessInfo+Helpers.swift rename to Example/iOSExample/Library/ProcessInfo+Helpers.swift diff --git a/Example/Example/Library/TextView+Helpers.swift b/Example/iOSExample/Library/TextView+Helpers.swift similarity index 100% rename from Example/Example/Library/TextView+Helpers.swift rename to Example/iOSExample/Library/TextView+Helpers.swift diff --git a/Example/Example/Library/ThemeSetting.swift b/Example/iOSExample/Library/ThemeSetting.swift similarity index 100% rename from Example/Example/Library/ThemeSetting.swift rename to Example/iOSExample/Library/ThemeSetting.swift diff --git a/Example/Example/Library/UserDefaults+Helpers.swift b/Example/iOSExample/Library/UserDefaults+Helpers.swift similarity index 100% rename from Example/Example/Library/UserDefaults+Helpers.swift rename to Example/iOSExample/Library/UserDefaults+Helpers.swift diff --git a/Example/Example/Main/KeyboardToolsView.swift b/Example/iOSExample/Main/KeyboardToolsView.swift similarity index 100% rename from Example/Example/Main/KeyboardToolsView.swift rename to Example/iOSExample/Main/KeyboardToolsView.swift diff --git a/Example/Example/Main/MainView.swift b/Example/iOSExample/Main/MainView.swift similarity index 100% rename from Example/Example/Main/MainView.swift rename to Example/iOSExample/Main/MainView.swift diff --git a/Example/Example/Main/MainViewController.swift b/Example/iOSExample/Main/MainViewController.swift similarity index 100% rename from Example/Example/Main/MainViewController.swift rename to Example/iOSExample/Main/MainViewController.swift diff --git a/Example/Example/ThemePicker/ThemePickerPreviewCell.swift b/Example/iOSExample/ThemePicker/ThemePickerPreviewCell.swift similarity index 100% rename from Example/Example/ThemePicker/ThemePickerPreviewCell.swift rename to Example/iOSExample/ThemePicker/ThemePickerPreviewCell.swift diff --git a/Example/Example/ThemePicker/ThemePickerViewController.swift b/Example/iOSExample/ThemePicker/ThemePickerViewController.swift similarity index 100% rename from Example/Example/ThemePicker/ThemePickerViewController.swift rename to Example/iOSExample/ThemePicker/ThemePickerViewController.swift diff --git a/Example/Example/Example.entitlements b/Example/iOSExample/iOSExample.entitlements similarity index 100% rename from Example/Example/Example.entitlements rename to Example/iOSExample/iOSExample.entitlements From a26d6bc4dcfe4627f29723f90c23bef202745ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 28 Jan 2023 23:46:03 +0100 Subject: [PATCH 005/232] Rerender line number when frame changes --- Sources/Runestone/TextView/Gutter/LineNumberView.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/Runestone/TextView/Gutter/LineNumberView.swift b/Sources/Runestone/TextView/Gutter/LineNumberView.swift index 5144bc2e9..8500afe77 100644 --- a/Sources/Runestone/TextView/Gutter/LineNumberView.swift +++ b/Sources/Runestone/TextView/Gutter/LineNumberView.swift @@ -27,6 +27,13 @@ final class LineNumberView: MultiPlatformView, ReusableView { } } } + override var frame: CGRect { + didSet { + if frame != oldValue { + setNeedsDisplay() + } + } + } init() { super.init(frame: .zero) From d5e5788d6a04df2b0175b015c9b97b4d9cb001dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 28 Jan 2023 23:47:05 +0100 Subject: [PATCH 006/232] Removes compiler version checks --- .../TextView/Appearance/DefaultTheme.swift | 2 +- .../Runestone/TextView/Appearance/Theme.swift | 4 ++-- .../Core/iOS/EditMenuController.swift | 24 +++++-------------- .../iOS/UITextSearchingHelper.swift | 2 -- 4 files changed, 9 insertions(+), 23 deletions(-) diff --git a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift index e52c10dea..8f04f890d 100644 --- a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift +++ b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift @@ -69,7 +69,7 @@ public final class DefaultTheme: Runestone.Theme { } } -#if compiler(>=5.7) && os(iOS) +#if os(iOS) @available(iOS 16.0, *) public func highlightedRange(forFoundTextRange foundTextRange: NSRange, ofStyle style: UITextSearchFoundTextStyle) -> HighlightedRange? { switch style { diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index fe208ddf8..bbdce502e 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -55,7 +55,7 @@ public protocol Theme: AnyObject { /// /// See for more information on higlight names. func shadow(for highlightName: String) -> NSShadow? -#if compiler(>=5.7) && os(iOS) +#if os(iOS) /// 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 . @@ -103,7 +103,7 @@ public extension Theme { return nil } -#if compiler(>=5.7) && os(iOS) +#if os(iOS) @available(iOS 16, *) func highlightedRange(forFoundTextRange foundTextRange: NSRange, ofStyle style: UITextSearchFoundTextStyle) -> HighlightedRange? { switch style { diff --git a/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift b/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift index fc5bd3478..60ee3e08b 100644 --- a/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift +++ b/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift @@ -12,24 +12,18 @@ protocol EditMenuControllerDelegate: AnyObject { final class EditMenuController: NSObject { weak var delegate: EditMenuControllerDelegate? -#if compiler(>=5.7) @available(iOS 16, *) private var editMenuInteraction: UIEditMenuInteraction? { - return _editMenuInteraction as? 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) { @@ -37,7 +31,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) @@ -46,9 +39,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? { @@ -60,14 +50,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. @@ -101,12 +89,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 { @@ -115,4 +104,3 @@ extension EditMenuController: UIEditMenuInteractionDelegate { } } #endif -#endif diff --git a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift index 521ec89b6..ba4b6e495 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift @@ -3,7 +3,6 @@ import UIKit final class UITextSearchingHelper: NSObject { weak var textView: TextView? -#if compiler(>=5.7) var isFindInteractionEnabled = false { didSet { if isFindInteractionEnabled != oldValue { @@ -31,7 +30,6 @@ final class UITextSearchingHelper: NSObject { } } private var _findInteraction: Any? -#endif private let queue = OperationQueue() private var _textView: TextView { From 12d482c064b3c7a0bf9c9fafb3397fcd695ae20b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 28 Jan 2023 23:47:35 +0100 Subject: [PATCH 007/232] Improves formatting --- Sources/Runestone/Library/ViewReuseQueue.swift | 4 ++-- .../TextView/Highlight/HighlightNavigationController.swift | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/Runestone/Library/ViewReuseQueue.swift b/Sources/Runestone/Library/ViewReuseQueue.swift index 82bc3afba..31876b9e4 100644 --- a/Sources/Runestone/Library/ViewReuseQueue.swift +++ b/Sources/Runestone/Library/ViewReuseQueue.swift @@ -15,14 +15,14 @@ final class ViewReuseQueue = [] - init() { #if os(iOS) NotificationCenter.default.addObserver( self, selector: #selector(clearMemory), name: UIApplication.didReceiveMemoryWarningNotification, - object: nil) + object: nil + ) #endif } diff --git a/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift b/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift index 60e501abe..31ddeb8fe 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift @@ -3,7 +3,8 @@ import Foundation protocol HighlightNavigationControllerDelegate: AnyObject { func highlightNavigationController( _ controller: HighlightNavigationController, - shouldNavigateTo highlightNavigationRange: HighlightNavigationRange) + shouldNavigateTo highlightNavigationRange: HighlightNavigationRange + ) } struct HighlightNavigationRange { From 0b5b38ad891a98af4cec0a924f7914bc496a4fbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 00:09:46 +0100 Subject: [PATCH 008/232] Fixes firstRect --- Sources/Runestone/TextView/LineController/LineController.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/LineController/LineController.swift b/Sources/Runestone/TextView/LineController/LineController.swift index c3312f089..d47ff4040 100644 --- a/Sources/Runestone/TextView/LineController/LineController.swift +++ b/Sources/Runestone/TextView/LineController/LineController.swift @@ -445,7 +445,8 @@ extension LineController { let finalIndex = min(lineRange.location + lineRange.length, range.location + range.length) let xStart = CTLineGetOffsetForStringIndex(line, index, nil) let xEnd = CTLineGetOffsetForStringIndex(line, finalIndex, nil) - return CGRect(x: xStart, y: lineFragment.yPosition, width: xEnd - xStart, height: lineFragment.scaledSize.height) + let yPosition = lineFragment.yPosition + (lineFragment.scaledSize.height - lineFragment.baseSize.height) / 2 + return CGRect(x: xStart, y: yPosition, width: xEnd - xStart, height: lineFragment.baseSize.height) } } return CGRect(x: 0, y: 0, width: 0, height: estimatedLineFragmentHeight * lineFragmentHeightMultiplier) From 572b365996665a09022ed33e0619bbb951cfc0b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 00:13:44 +0100 Subject: [PATCH 009/232] Improves formatting --- .../TextView/Core/LayoutManager.swift | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index d3e6d9023..b66c77418 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -152,16 +152,18 @@ final class LayoutManager { private var needsLayout = false private var needsLayoutLineSelection = false - init(lineManager: LineManager, - languageMode: InternalLanguageMode, - stringView: StringView, - lineControllerStorage: LineControllerStorage, - contentSizeService: ContentSizeService, - gutterWidthService: GutterWidthService, - caretRectService: CaretRectService, - selectionRectService: SelectionRectService, - highlightService: HighlightService, - invisibleCharacterConfiguration: InvisibleCharacterConfiguration) { + init( + lineManager: LineManager, + languageMode: InternalLanguageMode, + stringView: StringView, + lineControllerStorage: LineControllerStorage, + contentSizeService: ContentSizeService, + gutterWidthService: GutterWidthService, + caretRectService: CaretRectService, + selectionRectService: SelectionRectService, + highlightService: HighlightService, + invisibleCharacterConfiguration: InvisibleCharacterConfiguration + ) { self.lineManager = lineManager self.languageMode = languageMode self.stringView = stringView @@ -235,10 +237,12 @@ final class LayoutManager { let localNeedleLocation = needleRange.location - startLocation let localNeedleLength = min(needleRange.length, previewRange.length) let needleInPreviewRange = NSRange(location: localNeedleLocation, length: localNeedleLength) - return TextPreview(needleRange: needleRange, - previewRange: previewRange, - needleInPreviewRange: needleInPreviewRange, - lineControllers: lineControllers) + return TextPreview( + needleRange: needleRange, + previewRange: previewRange, + needleInPreviewRange: needleInPreviewRange, + lineControllers: lineControllers + ) } } @@ -421,8 +425,10 @@ extension LayoutManager { let lineFragment = lineFragmentController.lineFragment var lineFragmentFrame: CGRect = .zero appearedLineFragmentIDs.insert(lineFragment.id) - lineFragmentController.highlightedRangeFragments = highlightService.highlightedRangeFragments(for: lineFragment, - inLineWithID: line.id) + lineFragmentController.highlightedRangeFragments = highlightService.highlightedRangeFragments( + for: lineFragment, + inLineWithID: line.id + ) layoutLineFragmentView(for: lineFragmentController, lineYPosition: lineYPosition, lineFragmentFrame: &lineFragmentFrame) maxY = lineFragmentFrame.maxY } @@ -488,7 +494,11 @@ extension LayoutManager { lineNumberView.frame = CGRect(x: xPosition, y: yPosition, width: gutterWidthService.lineNumberWidth, height: fontLineHeight) } - private func layoutLineFragmentView(for lineFragmentController: LineFragmentController, lineYPosition: CGFloat, lineFragmentFrame: inout CGRect) { + private func layoutLineFragmentView( + for lineFragmentController: LineFragmentController, + lineYPosition: CGFloat, + lineFragmentFrame: inout CGRect + ) { let lineFragment = lineFragmentController.lineFragment let lineFragmentView = lineFragmentViewReuseQueue.dequeueView(forKey: lineFragment.id) if lineFragmentView.superview == nil { From 4388e34c343910c5a7d9053ade8b1868c5264a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 00:38:16 +0100 Subject: [PATCH 010/232] Improves formatting --- .../iOSExample/Main/MainViewController.swift | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Example/iOSExample/Main/MainViewController.swift b/Example/iOSExample/Main/MainViewController.swift index aeeeb8b35..89a2b7e1f 100644 --- a/Example/iOSExample/Main/MainViewController.swift +++ b/Example/iOSExample/Main/MainViewController.swift @@ -15,14 +15,18 @@ final class MainViewController: UIViewController { toolsView = KeyboardToolsView(textView: contentView.textView) 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) { From 2417a2902a80699c866111ca89baa45816c01c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 00:38:25 +0100 Subject: [PATCH 011/232] Removes compiler version checks --- Example/iOSExample/Main/MainViewController.swift | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Example/iOSExample/Main/MainViewController.swift b/Example/iOSExample/Main/MainViewController.swift index 89a2b7e1f..4cc360761 100644 --- a/Example/iOSExample/Main/MainViewController.swift +++ b/Example/iOSExample/Main/MainViewController.swift @@ -39,11 +39,9 @@ final class MainViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() -#if compiler(>=5.7) if #available(iOS 16, *) { contentView.textView.isFindInteractionEnabled = true } -#endif contentView.textView.inputAccessoryView = toolsView setupMenuButton() setupTextView() @@ -52,7 +50,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) @@ -62,7 +59,6 @@ private extension MainViewController { @objc private func presentFindAndReplace() { contentView.textView.findInteraction?.presentFindNavigator(showingReplace: true) } -#endif private func setupTextView() { var text = "" @@ -93,7 +89,6 @@ private extension MainViewController { private func makeFeaturesMenuElements() -> [UIMenuElement] { var menuElements: [UIMenuElement] = [] -#if compiler(>=5.7) if #available(iOS 16, *) { menuElements += [ UIMenu(options: .displayInline, children: [ @@ -106,7 +101,6 @@ private extension MainViewController { ]) ] } -#endif menuElements += [ UIAction(title: "Go to Line") { [weak self] _ in self?.presentGoToLineAlert() From 6b074138b57a85df7a64afebd76bd61832f18549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 00:38:35 +0100 Subject: [PATCH 012/232] Prepares for Mac example --- Example/Example.xcodeproj/project.pbxproj | 185 ++++- Example/Languages/Package.swift | 2 +- Example/MacExample/AppDelegate.swift | 12 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 58 ++ .../MacExample/Assets.xcassets/Contents.json | 6 + Example/MacExample/Base.lproj/Main.storyboard | 719 ++++++++++++++++++ Example/MacExample/MacExample.entitlements | 10 + Example/MacExample/MainViewController.swift | 33 + Example/Themes/Package.swift | 2 +- 10 files changed, 1035 insertions(+), 3 deletions(-) create mode 100644 Example/MacExample/AppDelegate.swift create mode 100644 Example/MacExample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Example/MacExample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Example/MacExample/Assets.xcassets/Contents.json create mode 100644 Example/MacExample/Base.lproj/Main.storyboard create mode 100644 Example/MacExample/MacExample.entitlements create mode 100644 Example/MacExample/MainViewController.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 033f49e64..7a4fccbff 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -12,6 +12,12 @@ 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 */; }; + 729ECE4F2983F5B60049AFF5 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 729ECE4E2983F5B60049AFF5 /* AppDelegate.swift */; }; + 729ECE512983F5B60049AFF5 /* MainViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 729ECE502983F5B60049AFF5 /* MainViewController.swift */; }; + 729ECE532983F5B60049AFF5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 729ECE522983F5B60049AFF5 /* Assets.xcassets */; }; + 729ECE562983F5B60049AFF5 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 729ECE542983F5B60049AFF5 /* Main.storyboard */; }; + 729ECE5C2983F9B90049AFF5 /* RunestoneJavaScriptLanguage in Frameworks */ = {isa = PBXBuildFile; productRef = 729ECE5B2983F9B90049AFF5 /* RunestoneJavaScriptLanguage */; }; + 729ECE5E2983F9B90049AFF5 /* Runestone in Frameworks */ = {isa = PBXBuildFile; productRef = 729ECE5D2983F9B90049AFF5 /* Runestone */; }; 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 */; }; @@ -33,6 +39,12 @@ /* Begin PBXFileReference section */ 7216EAC62829A16C001B6D39 /* Themes */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Themes; sourceTree = ""; }; 7243F9BA282D73E9005AAABF /* iOSExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iOSExample.entitlements; sourceTree = ""; }; + 729ECE4C2983F5B60049AFF5 /* MacExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 729ECE4E2983F5B60049AFF5 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 729ECE502983F5B60049AFF5 /* MainViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainViewController.swift; sourceTree = ""; }; + 729ECE522983F5B60049AFF5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 729ECE552983F5B60049AFF5 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 729ECE572983F5B60049AFF5 /* MacExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MacExample.entitlements; sourceTree = ""; }; 72AC54762826B1F00037ED21 /* Languages */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Languages; sourceTree = ""; }; 72AC54772826B23D0037ED21 /* Runestone */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Runestone; path = ..; sourceTree = ""; }; 72D2718129126F190070FA88 /* ProcessInfo+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ProcessInfo+Helpers.swift"; sourceTree = ""; }; @@ -55,6 +67,15 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 729ECE492983F5B60049AFF5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 729ECE5E2983F9B90049AFF5 /* Runestone in Frameworks */, + 729ECE5C2983F9B90049AFF5 /* RunestoneJavaScriptLanguage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; ACFDF4AA27983BAA00059A1B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -71,6 +92,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 729ECE4D2983F5B60049AFF5 /* MacExample */ = { + isa = PBXGroup; + children = ( + 729ECE4E2983F5B60049AFF5 /* AppDelegate.swift */, + 729ECE502983F5B60049AFF5 /* MainViewController.swift */, + 729ECE522983F5B60049AFF5 /* Assets.xcassets */, + 729ECE542983F5B60049AFF5 /* Main.storyboard */, + 729ECE572983F5B60049AFF5 /* MacExample.entitlements */, + ); + path = MacExample; + sourceTree = ""; + }; 72AC54722826B0E40037ED21 /* Packages */ = { isa = PBXGroup; children = ( @@ -127,6 +160,7 @@ children = ( 72AC54722826B0E40037ED21 /* Packages */, ACFDF4AF27983BAA00059A1B /* iOSExample */, + 729ECE4D2983F5B60049AFF5 /* MacExample */, ACFDF4AE27983BAA00059A1B /* Products */, ACFDF4C827983DA900059A1B /* Frameworks */, ); @@ -136,6 +170,7 @@ isa = PBXGroup; children = ( ACFDF4AD27983BAA00059A1B /* iOSExample.app */, + 729ECE4C2983F5B60049AFF5 /* MacExample.app */, ); name = Products; sourceTree = ""; @@ -165,6 +200,28 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 729ECE4B2983F5B60049AFF5 /* MacExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 729ECE582983F5B60049AFF5 /* Build configuration list for PBXNativeTarget "MacExample" */; + buildPhases = ( + 729ECE482983F5B60049AFF5 /* Sources */, + 729ECE492983F5B60049AFF5 /* Frameworks */, + 729ECE4A2983F5B60049AFF5 /* Resources */, + 729ECE7C2984015C0049AFF5 /* SwiftLint */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = MacExample; + packageProductDependencies = ( + 729ECE5B2983F9B90049AFF5 /* RunestoneJavaScriptLanguage */, + 729ECE5D2983F9B90049AFF5 /* Runestone */, + ); + productName = MacExample; + productReference = 729ECE4C2983F5B60049AFF5 /* MacExample.app */; + productType = "com.apple.product-type.application"; + }; ACFDF4AC27983BAA00059A1B /* iOSExample */ = { isa = PBXNativeTarget; buildConfigurationList = ACFDF4C127983BAB00059A1B /* Build configuration list for PBXNativeTarget "iOSExample" */; @@ -198,9 +255,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1410; + LastSwiftUpdateCheck = 1420; LastUpgradeCheck = 1320; TargetAttributes = { + 729ECE4B2983F5B60049AFF5 = { + CreatedOnToolsVersion = 14.2; + }; ACFDF4AC27983BAA00059A1B = { CreatedOnToolsVersion = 13.2; }; @@ -222,11 +282,21 @@ projectRoot = ""; targets = ( ACFDF4AC27983BAA00059A1B /* iOSExample */, + 729ECE4B2983F5B60049AFF5 /* MacExample */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 729ECE4A2983F5B60049AFF5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 729ECE532983F5B60049AFF5 /* Assets.xcassets in Resources */, + 729ECE562983F5B60049AFF5 /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; ACFDF4AB27983BAA00059A1B /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -239,6 +309,25 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 729ECE7C2984015C0049AFF5 /* SwiftLint */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = SwiftLint; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "export PATH=\"$PATH:/opt/homebrew/bin\"\nif which swiftlint > /dev/null; then\n swiftlint lint --config ../.swiftlint.yml\nelse\n echo \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi\n"; + }; ACFDF4CD27983DE500059A1B /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -261,6 +350,15 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 729ECE482983F5B60049AFF5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 729ECE512983F5B60049AFF5 /* MainViewController.swift in Sources */, + 729ECE4F2983F5B60049AFF5 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; ACFDF4A927983BAA00059A1B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -284,6 +382,14 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ + 729ECE542983F5B60049AFF5 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 729ECE552983F5B60049AFF5 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; ACFDF4BB27983BAB00059A1B /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; children = ( @@ -295,6 +401,66 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 729ECE592983F5B60049AFF5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = MacExample/MacExample.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8NQFWJHC63; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMainStoryboardFile = Main; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dk.simonbs.MacExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 729ECE5A2983F5B60049AFF5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = MacExample/MacExample.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 8NQFWJHC63; + ENABLE_HARDENED_RUNTIME = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INFOPLIST_KEY_NSMainStoryboardFile = Main; + INFOPLIST_KEY_NSPrincipalClass = NSApplication; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 11.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dk.simonbs.MacExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; ACFDF4BF27983BAB00059A1B /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -474,6 +640,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 729ECE582983F5B60049AFF5 /* Build configuration list for PBXNativeTarget "MacExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 729ECE592983F5B60049AFF5 /* Debug */, + 729ECE5A2983F5B60049AFF5 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; ACFDF4A827983BAA00059A1B /* Build configuration list for PBXProject "Example" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -515,6 +690,14 @@ isa = XCSwiftPackageProductDependency; productName = RunestoneTomorrowTheme; }; + 729ECE5B2983F9B90049AFF5 /* RunestoneJavaScriptLanguage */ = { + isa = XCSwiftPackageProductDependency; + productName = RunestoneJavaScriptLanguage; + }; + 729ECE5D2983F9B90049AFF5 /* Runestone */ = { + isa = XCSwiftPackageProductDependency; + productName = Runestone; + }; 72AC54802826B2A90037ED21 /* Runestone */ = { isa = XCSwiftPackageProductDependency; productName = Runestone; diff --git a/Example/Languages/Package.swift b/Example/Languages/Package.swift index 2a6e1531e..5341e03bc 100644 --- a/Example/Languages/Package.swift +++ b/Example/Languages/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "Languages", - platforms: [.iOS(.v14)], + platforms: [.iOS(.v14), .macOS(.v11)], products: [ .library(name: "RunestoneJavaScriptLanguage", targets: ["RunestoneJavaScriptLanguage"]) ], diff --git a/Example/MacExample/AppDelegate.swift b/Example/MacExample/AppDelegate.swift new file mode 100644 index 000000000..28da7277b --- /dev/null +++ b/Example/MacExample/AppDelegate.swift @@ -0,0 +1,12 @@ +import Cocoa + +@main +final class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ aNotification: Notification) {} + + func applicationWillTerminate(_ aNotification: Notification) {} + + func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/Example/MacExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/MacExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Example/MacExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..3f00db43e --- /dev/null +++ b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images" : [ + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/MacExample/Assets.xcassets/Contents.json b/Example/MacExample/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Example/MacExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/MacExample/Base.lproj/Main.storyboard b/Example/MacExample/Base.lproj/Main.storyboard new file mode 100644 index 000000000..eae9f485c --- /dev/null +++ b/Example/MacExample/Base.lproj/Main.storyboard @@ -0,0 +1,719 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + Default + + + + + + + Left to Right + + + + + + + Right to Left + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Example/MacExample/MacExample.entitlements b/Example/MacExample/MacExample.entitlements new file mode 100644 index 000000000..f2ef3ae02 --- /dev/null +++ b/Example/MacExample/MacExample.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + + diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift new file mode 100644 index 000000000..db937044f --- /dev/null +++ b/Example/MacExample/MainViewController.swift @@ -0,0 +1,33 @@ +import Cocoa +import Runestone + +final class MainViewController: NSViewController { + private let textView: TextView = { + let this = TextView() + this.translatesAutoresizingMaskIntoConstraints = false + return this + }() + + override var acceptsFirstResponder: Bool { + return true + } + + override func viewDidLoad() { + super.viewDidLoad() + view.wantsLayer = true + view.layer?.backgroundColor = NSColor.purple.cgColor + setupTextView() + } +} + +private extension MainViewController { + private func setupTextView() { + view.addSubview(textView) + NSLayoutConstraint.activate([ + textView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + textView.topAnchor.constraint(equalTo: view.topAnchor), + textView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + } +} diff --git a/Example/Themes/Package.swift b/Example/Themes/Package.swift index 3c1e39023..874ea9e96 100644 --- a/Example/Themes/Package.swift +++ b/Example/Themes/Package.swift @@ -5,7 +5,7 @@ import PackageDescription let package = Package( name: "Themes", - platforms: [.iOS(.v14)], + platforms: [.iOS(.v14), .macOS(.v11)], products: [ .library(name: "RunestoneTomorrowTheme", targets: ["RunestoneTomorrowTheme"]), .library(name: "RunestoneTomorrowNightTheme", targets: ["RunestoneTomorrowNightTheme"]), From f8362d1766ccf30f41a4af0dbd42b1f88135e360 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 00:38:53 +0100 Subject: [PATCH 013/232] WIP multi-platform --- Package.swift | 2 +- .../MultiPlaformScrollView.swift | 7 + .../MultiPlatform/MultiPlatformColor.swift | 0 .../MultiPlatformEdgeInsets.swift | 0 .../MultiPlatform/MultiPlatformFont.swift | 0 .../MultiPlatform/MultiPlatformView.swift | 0 .../TextView/Core/LayoutManager.swift | 17 +- .../TextView/Core/Mac/TextView_Mac.swift | 76 +- .../Runestone/TextView/Core/TextView.swift | 1217 +++++++++++++ .../TextViewController+ContentSize.swift | 42 + .../TextViewController+Editing.swift | 140 ++ .../TextViewController+GoToLine.swift | 27 + .../TextViewController+Indentation.swift | 16 + .../TextViewController+Layout.swift | 64 + .../TextViewController+MoveLines.swift | 32 + .../TextViewController+Scrolling.swift | 65 + .../TextViewController+Selection.swift | 10 + .../TextViewController+Syntax.swift | 11 + .../TextViewController+UndoRedo.swift | 25 + .../TextViewController.swift | 747 ++++++++ .../TextView/Core/iOS/TextInputView.swift | 156 +- .../Core/iOS/TextView+UITextInput.swift | 363 ++++ .../TextView/Core/iOS/TextView_iOS.swift | 1503 ----------------- 23 files changed, 2888 insertions(+), 1632 deletions(-) create mode 100644 Sources/Runestone/MultiPlatform/MultiPlaformScrollView.swift rename Sources/Runestone/{Library => }/MultiPlatform/MultiPlatformColor.swift (100%) rename Sources/Runestone/{Library => }/MultiPlatform/MultiPlatformEdgeInsets.swift (100%) rename Sources/Runestone/{Library => }/MultiPlatform/MultiPlatformFont.swift (100%) rename Sources/Runestone/{Library => }/MultiPlatform/MultiPlatformView.swift (100%) create mode 100644 Sources/Runestone/TextView/Core/TextView.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+GoToLine.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+Indentation.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+MoveLines.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+Syntax.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift create mode 100644 Sources/Runestone/TextView/Core/iOS/TextView+UITextInput.swift delete mode 100644 Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift diff --git a/Package.swift b/Package.swift index 851a7cf59..d476ffa1e 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,7 @@ let package = Package( defaultLocalization: "en", platforms: [ .iOS(.v14), - .macOS(.v13) + .macOS(.v11) ], products: [ .library(name: "Runestone", targets: ["Runestone"]) diff --git a/Sources/Runestone/MultiPlatform/MultiPlaformScrollView.swift b/Sources/Runestone/MultiPlatform/MultiPlaformScrollView.swift new file mode 100644 index 000000000..1e8ba1db2 --- /dev/null +++ b/Sources/Runestone/MultiPlatform/MultiPlaformScrollView.swift @@ -0,0 +1,7 @@ +#if os(macOS) +import AppKit +public typealias MultiPlatformScrollView = NSScrollView +#else +import UIKit +public typealias MultiPlatformScrollView = UIScrollView +#endif diff --git a/Sources/Runestone/Library/MultiPlatform/MultiPlatformColor.swift b/Sources/Runestone/MultiPlatform/MultiPlatformColor.swift similarity index 100% rename from Sources/Runestone/Library/MultiPlatform/MultiPlatformColor.swift rename to Sources/Runestone/MultiPlatform/MultiPlatformColor.swift diff --git a/Sources/Runestone/Library/MultiPlatform/MultiPlatformEdgeInsets.swift b/Sources/Runestone/MultiPlatform/MultiPlatformEdgeInsets.swift similarity index 100% rename from Sources/Runestone/Library/MultiPlatform/MultiPlatformEdgeInsets.swift rename to Sources/Runestone/MultiPlatform/MultiPlatformEdgeInsets.swift diff --git a/Sources/Runestone/Library/MultiPlatform/MultiPlatformFont.swift b/Sources/Runestone/MultiPlatform/MultiPlatformFont.swift similarity index 100% rename from Sources/Runestone/Library/MultiPlatform/MultiPlatformFont.swift rename to Sources/Runestone/MultiPlatform/MultiPlatformFont.swift diff --git a/Sources/Runestone/Library/MultiPlatform/MultiPlatformView.swift b/Sources/Runestone/MultiPlatform/MultiPlatformView.swift similarity index 100% rename from Sources/Runestone/Library/MultiPlatform/MultiPlatformView.swift rename to Sources/Runestone/MultiPlatform/MultiPlatformView.swift diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index b66c77418..2d7df2a6d 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -12,16 +12,9 @@ protocol LayoutManagerDelegate: AnyObject { final class LayoutManager { weak var delegate: LayoutManagerDelegate? - weak var gutterParentView: MultiPlatformView? { + weak var containerView: MultiPlatformView? { didSet { - if gutterParentView != oldValue { - setupViewHierarchy() - } - } - } - weak var textInputView: MultiPlatformView? { - didSet { - if textInputView != oldValue { + if containerView != oldValue { setupViewHierarchy() } } @@ -538,9 +531,9 @@ extension LayoutManager { let allLineNumberKeys = lineFragmentViewReuseQueue.visibleViews.keys lineFragmentViewReuseQueue.enqueueViews(withKeys: Set(allLineNumberKeys)) // Add views to view hierarchy - textInputView?.addSubview(lineSelectionBackgroundView) - textInputView?.addSubview(linesContainerView) - gutterParentView?.addSubview(gutterContainerView) + containerView?.addSubview(lineSelectionBackgroundView) + containerView?.addSubview(linesContainerView) + containerView?.addSubview(gutterContainerView) gutterContainerView.addSubview(gutterBackgroundView) gutterContainerView.addSubview(gutterSelectionBackgroundView) gutterContainerView.addSubview(lineNumbersContainerView) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 35b5a5992..26a12a986 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -1,40 +1,40 @@ #if os(macOS) -import AppKit - -public final class TextView: NSView { - private let textInputClientView: TextInputClientView = { - let this = TextInputClientView() - this.translatesAutoresizingMaskIntoConstraints = false - return this - }() - - init() { - super.init(frame: .zero) - wantsLayer = true - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - public override func keyDown(with event: NSEvent) { - NSCursor.setHiddenUntilMouseMoves(true) - let didInputContextHandleEvent = inputContext?.handleEvent(event) ?? false - if !didInputContextHandleEvent { - super.keyDown(with: event) - } - } -} - -private extension TextView { - private func setupTextInputClientView() { - addSubview(textInputClientView) - NSLayoutConstraint.activate([ - textInputClientView.leadingAnchor.constraint(equalTo: leadingAnchor), - textInputClientView.trailingAnchor.constraint(equalTo: trailingAnchor), - textInputClientView.topAnchor.constraint(equalTo: topAnchor), - textInputClientView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - } -} +//import AppKit +// +//public final class TextView: NSView { +// private let textInputClientView: TextInputClientView = { +// let this = TextInputClientView() +// this.translatesAutoresizingMaskIntoConstraints = false +// return this +// }() +// +// public init() { +// super.init(frame: .zero) +// wantsLayer = true +// } +// +// required init?(coder: NSCoder) { +// fatalError("init(coder:) has not been implemented") +// } +// +// public override func keyDown(with event: NSEvent) { +// NSCursor.setHiddenUntilMouseMoves(true) +// let didInputContextHandleEvent = inputContext?.handleEvent(event) ?? false +// if !didInputContextHandleEvent { +// super.keyDown(with: event) +// } +// } +//} +// +//private extension TextView { +// private func setupTextInputClientView() { +// addSubview(textInputClientView) +// NSLayoutConstraint.activate([ +// textInputClientView.leadingAnchor.constraint(equalTo: leadingAnchor), +// textInputClientView.trailingAnchor.constraint(equalTo: trailingAnchor), +// textInputClientView.topAnchor.constraint(equalTo: topAnchor), +// textInputClientView.bottomAnchor.constraint(equalTo: bottomAnchor) +// ]) +// } +//} #endif diff --git a/Sources/Runestone/TextView/Core/TextView.swift b/Sources/Runestone/TextView/Core/TextView.swift new file mode 100644 index 000000000..66c71fd1b --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextView.swift @@ -0,0 +1,1217 @@ +// swiftlint:disable file_length type_body_length +import CoreText +import UIKit + +/// A type similiar to UITextView with features commonly found in code editors. +/// +/// `TextView` is a performant implementation of a text view with features such as showing line numbers, searching for text and replacing results, syntax highlighting, showing invisible characters and more. +/// +/// The type does not subclass `UITextView` but its interface is kept close to `UITextView`. +/// +/// When initially configuring the `TextView` with a theme, a language and the text to be shown, it is recommended to use the ``setState(_:addUndoAction:)`` function. +/// The function takes an instance of ``TextViewState`` as input which can be created on a background queue to avoid blocking the main queue while doing the initial parse of a text. +open class TextView: UIScrollView { + @objc public weak var inputDelegate: UITextInputDelegate? + /// Returns a Boolean value indicating whether this object can become the first responder. + override public var canBecomeFirstResponder: Bool { + return !isFirstResponder && isEditable + } + /// Delegate to receive callbacks for events triggered by the editor. + public weak var editorDelegate: TextViewDelegate? + /// Whether the text view is in a state where the contents can be edited. + public var isEditing: Bool { + textViewController.isEditing + } + /// The text that the text view displays. + public var text: String { + get { + return textViewController.text + } + set { + textViewController.text = newValue + contentSize = textViewController.contentSize + } + } + /// A Boolean value that indicates whether the text view is editable. + public var isEditable: Bool { + get { + return textViewController.isEditable + } + set { + if newValue != isEditable { + textViewController.isEditable = newValue + if !newValue { + installNonEditableInteraction() + } + } + } + } + /// A Boolean value that indicates whether the text view is selectable. + public var isSelectable: Bool { + get { + return textViewController.isSelectable + } + set { + if newValue != isSelectable { + textViewController.isSelectable = newValue + if !newValue { + installNonEditableInteraction() + } + } + } + } + /// The current selection range of the text view. + public var selectedRange: NSRange { + get { + if let selectedRange = textViewController.selectedRange { + return selectedRange + } else { + // UITextView returns the end of the document for the selectedRange by default. + return NSRange(location: textViewController.stringView.string.length, length: 0) + } + } + set { + if newValue != textViewController.selectedRange { + textViewController.selectedRange = newValue + handleTextSelectionChange() + } + } + } + /// Colors and fonts to be used by the editor. + public var theme: Theme { + get { + return textViewController.theme + } + set { + textViewController.theme = newValue + } + } + /// The autocorrection style for the text view. + public var autocorrectionType: UITextAutocorrectionType = .default + /// The autocapitalization style for the text view. + public var autocapitalizationType: UITextAutocapitalizationType = .sentences + /// The spell-checking style for the text view. + public var smartQuotesType: UITextSmartQuotesType = .default + /// The configuration state for smart dashes. + public var smartDashesType: UITextSmartDashesType = .default + /// The configuration state for the smart insertion and deletion of space characters. + public var smartInsertDeleteType: UITextSmartInsertDeleteType = .default + /// The spell-checking style for the text object. + public var spellCheckingType: UITextSpellCheckingType = .default + /// The keyboard type for the text view. + public var keyboardType: UIKeyboardType = .default + /// The appearance style of the keyboard for the text view. + public var keyboardAppearance: UIKeyboardAppearance = .default + /// The display of the return key. + public var returnKeyType: UIReturnKeyType = .default + /// Returns the undo manager used by the text view. + override public var undoManager: UndoManager? { + textViewController.timedUndoManager + } + /// The color of the insertion point. This can be used to control the color of the caret. + @objc public var insertionPointColor: UIColor = .label { + didSet { + if insertionPointColor != oldValue { + updateCaretColor() + } + } + } + /// The color of the selection bar. It is most common to set this to the same color as the color used for the insertion point. + @objc public var selectionBarColor: UIColor = .label { + didSet { + if selectionBarColor != oldValue { + updateCaretColor() + } + } + } + /// The color of the selection highlight. It is most common to set this to the same color as the color used for the insertion point. + @objc public var selectionHighlightColor: UIColor = .label.withAlphaComponent(0.2) { + didSet { + if selectionHighlightColor != oldValue { + updateCaretColor() + } + } + } + /// The point at which the origin of the content view is offset from the origin of the scroll view. + override public var contentOffset: CGPoint { + didSet { + if contentOffset != oldValue { + textViewController.viewport = CGRect(origin: contentOffset, size: frame.size) + } + } + } + /// Character pairs are used by the editor to automatically insert a trailing character when the user types the leading character. + /// + /// Common usages of this includes the \" character to surround strings and { } to surround a scope. + public var characterPairs: [CharacterPair] { + get { + return textViewController.characterPairs + } + set { + textViewController.characterPairs = newValue + } + } + /// Determines what should happen to the trailing component of a character pair when deleting the leading component. Defaults to `disabled` meaning that nothing will happen. + public var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode { + get { + return textViewController.characterPairTrailingComponentDeletionMode + } + set { + textViewController.characterPairTrailingComponentDeletionMode = newValue + } + } + /// Enable to show line numbers in the gutter. + public var showLineNumbers: Bool { + get { + return textViewController.showLineNumbers + } + set { + textViewController.showLineNumbers = newValue + } + } + /// Enable to show highlight the selected lines. The selection is only shown in the gutter when multiple lines are selected. + public var lineSelectionDisplayType: LineSelectionDisplayType { + get { + return textViewController.lineSelectionDisplayType + } + set { + textViewController.lineSelectionDisplayType = newValue + } + } + /// The text view renders invisible tabs when enabled. The `tabsSymbol` is used to render tabs. + public var showTabs: Bool { + get { + return textViewController.showTabs + } + set { + textViewController.showTabs = newValue + } + } + /// The text view renders invisible spaces when enabled. + /// + /// The `spaceSymbol` is used to render spaces. + public var showSpaces: Bool { + get { + return textViewController.showSpaces + } + set { + textViewController.showSpaces = newValue + } + } + /// The text view renders invisible spaces when enabled. + /// + /// The `nonBreakingSpaceSymbol` is used to render spaces. + public var showNonBreakingSpaces: Bool { + get { + return textViewController.showNonBreakingSpaces + } + set { + textViewController.showNonBreakingSpaces = newValue + } + } + /// The text view renders invisible line breaks when enabled. + /// + /// The `lineBreakSymbol` is used to render line breaks. + public var showLineBreaks: Bool { + get { + return textViewController.showLineBreaks + } + set { + textViewController.showLineBreaks = newValue + } + } + /// The text view renders invisible soft line breaks when enabled. + /// + /// The `softLineBreakSymbol` is used to render line breaks. These line breaks are typically represented by the U+2028 unicode character. Runestone does not provide any key commands for inserting these but supports rendering them. + public var showSoftLineBreaks: Bool { + get { + return textViewController.showSoftLineBreaks + } + set { + textViewController.showSoftLineBreaks = newValue + } + } + /// Symbol used to display tabs. + /// + /// The value is only used when invisible tab characters is enabled. The default is ▸. + /// + /// Common characters for this symbol include ▸, ⇥, ➜, ➞, and ❯. + public var tabSymbol: String { + get { + return textViewController.tabSymbol + } + set { + textViewController.tabSymbol = newValue + } + } + /// Symbol used to display spaces. + /// + /// The value is only used when showing invisible space characters is enabled. The default is ·. + /// + /// Common characters for this symbol include ·, •, and _. + public var spaceSymbol: String { + get { + return textViewController.spaceSymbol + } + set { + textViewController.spaceSymbol = newValue + } + } + /// Symbol used to display non-breaking spaces. + /// + /// The value is only used when showing invisible space characters is enabled. The default is ·. + /// + /// Common characters for this symbol include ·, •, and _. + public var nonBreakingSpaceSymbol: String { + get { + return textViewController.nonBreakingSpaceSymbol + } + set { + textViewController.nonBreakingSpaceSymbol = newValue + } + } + /// Symbol used to display line break. + /// + /// The value is only used when showing invisible line break characters is enabled. The default is ¬. + /// + /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. + public var lineBreakSymbol: String { + get { + return textViewController.lineBreakSymbol + } + set { + textViewController.lineBreakSymbol = newValue + } + } + /// Symbol used to display soft line breaks. + /// + /// The value is only used when showing invisible soft line break characters is enabled. The default is ¬. + /// + /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. + public var softLineBreakSymbol: String { + get { + return textViewController.softLineBreakSymbol + } + set { + textViewController.softLineBreakSymbol = newValue + } + } + /// The strategy used when indenting text. + public var indentStrategy: IndentStrategy { + get { + return textViewController.indentStrategy + } + set { + textViewController.indentStrategy = newValue + } + } + /// The amount of padding before the line numbers inside the gutter. + public var gutterLeadingPadding: CGFloat { + get { + return textViewController.gutterLeadingPadding + } + set { + textViewController.gutterLeadingPadding = newValue + } + } + /// The amount of padding after the line numbers inside the gutter. + public var gutterTrailingPadding: CGFloat { + get { + return textViewController.gutterTrailingPadding + } + set { + textViewController.gutterTrailingPadding = newValue + } + } + /// The minimum amount of characters to use for width calculation inside the gutter. + public var gutterMinimumCharacterCount: Int { + get { + return textViewController.gutterMinimumCharacterCount + } + set { + textViewController.gutterMinimumCharacterCount = newValue + } + } + /// The amount of spacing surrounding the lines. + public var textContainerInset: UIEdgeInsets { + get { + return textViewController.textContainerInset + } + set { + textViewController.textContainerInset = newValue + } + } + /// When line wrapping is disabled, users can scroll the text view horizontally to see the entire line. + /// + /// Line wrapping is enabled by default. + public var isLineWrappingEnabled: Bool { + get { + return textViewController.isLineWrappingEnabled + } + set { + textViewController.isLineWrappingEnabled = newValue + } + } + /// Line break mode for text view. The default value is .byWordWrapping meaning that wrapping occurs on word boundaries. + public var lineBreakMode: LineBreakMode { + get { + return textViewController.lineBreakMode + } + set { + textViewController.lineBreakMode = newValue + } + } + /// Width of the gutter. + public var gutterWidth: CGFloat { + textViewController.gutterWidth + } + /// The line-height is multiplied with the value. + public var lineHeightMultiplier: CGFloat { + get { + return textViewController.lineHeightMultiplier + } + set { + textViewController.lineHeightMultiplier = newValue + } + } + /// The number of points by which to adjust kern. The default value is 0 meaning that kerning is disabled. + public var kern: CGFloat { + get { + return textViewController.kern + } + set { + textViewController.kern = newValue + } + } + /// The text view shows a page guide when enabled. Use `pageGuideColumn` to specify the location of the page guide. + public var showPageGuide: Bool { + get { + return textViewController.showPageGuide + } + set { + textViewController.showPageGuide = newValue + } + } + /// Specifies the location of the page guide. Use `showPageGuide` to specify if the page guide should be shown. + public var pageGuideColumn: Int { + get { + return textViewController.pageGuideColumn + } + set { + textViewController.pageGuideColumn = newValue + } + } + /// Automatically scrolls the text view to show the caret when typing or moving the caret. + public var isAutomaticScrollEnabled: Bool { + get { + return textViewController.isAutomaticScrollEnabled + } + set { + textViewController.isAutomaticScrollEnabled = newValue + } + } + /// Amount of overscroll to add in the vertical direction. + /// + /// The overscroll is a factor of the scrollable area height and will not take into account any insets. 0 means no overscroll and 1 means an amount equal to the height of the text view. Detaults to 0. + public var verticalOverscrollFactor: CGFloat { + get { + return textViewController.verticalOverscrollFactor + } + set { + textViewController.verticalOverscrollFactor = newValue + } + } + /// Amount of overscroll to add in the horizontal direction. + /// + /// The overscroll is a factor of the scrollable area height and will not take into account any insets or the width of the gutter. 0 means no overscroll and 1 means an amount equal to the width of the text view. Detaults to 0. + public var horizontalOverscrollFactor: CGFloat { + get { + return textViewController.horizontalOverscrollFactor + } + set { + textViewController.horizontalOverscrollFactor = newValue + } + } + /// 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? { + textViewController.lengthOfInitallyLongestLine + } + /// Ranges in the text to be highlighted. The color defined by the background will be drawen behind the text. + public var highlightedRanges: [HighlightedRange] { + get { + return textViewController.highlightedRanges + } + set { + textViewController.highlightedRanges = newValue + } + } + /// Wheter the text view should loop when navigating through highlighted ranges using `selectPreviousHighlightedRange` or `selectNextHighlightedRange` on the text view. + public var highlightedRangeLoopingMode: HighlightedRangeLoopingMode { + get { + return textViewController.highlightedRangeLoopingMode + } + set { + textViewController.highlightedRangeLoopingMode = newValue + } + } + /// Line endings to use when inserting a line break. + /// + /// The value only affects new line breaks inserted in the text view and changing this value does not change the line endings of the text in the text view. Defaults to Unix (LF). + /// + /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)`` and ``TextView/replaceText(in:)``. + public var lineEndings: LineEnding = .lf + /// 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 + /// 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. + @available(iOS 16, *) + public var isFindInteractionEnabled: Bool { + get { + return textSearchingHelper.isFindInteractionEnabled + } + set { + textSearchingHelper.isFindInteractionEnabled = newValue + } + } + /// The text view’s built-in find interaction. + /// + /// Set to true to enable the text view's built-in find interaction. This method returns nil when the interaction isn't enabled. + /// + /// Call [`presentFindNavigator(showingReplace:)`](https://developer.apple.com/documentation/uikit/uifindinteraction/3975832-presentfindnavigator) on the UIFindInteraction object to invoke the find interaction and display the find panel. + @available(iOS 16, *) + public var findInteraction: UIFindInteraction? { + textSearchingHelper.findInteraction + } + /// The custom input accessory view to display when the receiver becomes the first responder. + override public var inputAccessoryView: UIView? { + get { + if isInputAccessoryViewEnabled { + return _inputAccessoryView + } else { + return nil + } + } + set { + _inputAccessoryView = newValue + } + } + + private(set) lazy var textViewController = TextViewController(textView: self) + private(set) lazy var customTokenizer = TextInputStringTokenizer( + textInput: self, + stringView: textViewController.stringView, + lineManager: textViewController.lineManager, + lineControllerStorage: textViewController.lineControllerStorage + ) + + var isRestoringPreviouslyDeletedText = false + var hasDeletedTextWithPendingLayoutSubviews = false + var notifyInputDelegateAboutSelectionChangeInLayoutSubviews = false + var notifyDelegateAboutSelectionChangeInLayoutSubviews = false + var didCallPositionFromPositionInDirectionWithOffset = false + + private let editableTextInteraction = UITextInteraction(for: .editable) + private let nonEditableTextInteraction = UITextInteraction(for: .nonEditable) + private let textSearchingHelper = UITextSearchingHelper() + private let editMenuController = EditMenuController() + private let keyboardObserver = KeyboardObserver() + private var isInputAccessoryViewEnabled = false + private var _inputAccessoryView: UIView? + private let tapGestureRecognizer = QuickTapGestureRecognizer() + + // Store a reference to instances of the private type UITextRangeAdjustmentGestureRecognizer in order to track adjustments + // to the selected text range and scroll the text view when the handles approach the bottom. + // The approach is based on the one described in Steve Shephard's blog post "Adventures with UITextInteraction". + // https://steveshepard.com/blog/adventures-with-uitextinteraction/ + private var textRangeAdjustmentGestureRecognizers: Set = [] + private var previousSelectedRangeDuringGestureHandling: NSRange? + private var isPerformingNonEditableTextInteraction = false + private var delegateAllowsEditingToBegin: Bool { + guard isEditable else { + return false + } + if let editorDelegate = editorDelegate { + return editorDelegate.textViewShouldBeginEditing(self) + } else { + return true + } + } + private var shouldEndEditing: Bool { + if let editorDelegate = editorDelegate { + return editorDelegate.textViewShouldEndEditing(self) + } else { + return true + } + } + + /// Create a new text view. + /// - Parameter frame: The frame rectangle of the text view. + public override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .white + textViewController.textView = self + editableTextInteraction.textInput = self + nonEditableTextInteraction.textInput = self + editableTextInteraction.delegate = self + nonEditableTextInteraction.delegate = self + tapGestureRecognizer.delegate = self + tapGestureRecognizer.addTarget(self, action: #selector(handleTap(_:))) + addGestureRecognizer(tapGestureRecognizer) + installNonEditableInteraction() + keyboardObserver.delegate = self + textSearchingHelper.textView = self + editMenuController.delegate = self + editMenuController.setupEditMenu(in: self) + textViewController.highlightNavigationController.delegate = self + } + + /// The initializer has not been implemented. + /// - Parameter coder: Not used. + required public init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Tells the view that its window object changed. + open override func didMoveToWindow() { + super.didMoveToWindow() + textViewController.performFullLayoutIfNeeded() + } + + /// Lays out subviews. + open override func layoutSubviews() { + super.layoutSubviews() + hasDeletedTextWithPendingLayoutSubviews = false + textViewController.scrollViewWidth = frame.width + textViewController.layout() + // 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 notifyInputDelegateAboutSelectionChangeInLayoutSubviews { + inputDelegate?.selectionWillChange(self) + inputDelegate?.selectionDidChange(self) + } + if notifyDelegateAboutSelectionChangeInLayoutSubviews { + notifyDelegateAboutSelectionChangeInLayoutSubviews = false + handleTextSelectionChange() + } + textViewController.handleContentSizeUpdateIfNeeded() + textViewController.viewport = CGRect(origin: contentOffset, size: frame.size) + } + + /// Called when the safe area of the view changes. + override open func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + textViewController.safeAreaInsets = safeAreaInsets + contentSize = textViewController.contentSize + layoutIfNeeded() + } + + /// Asks UIKit to make this object the first responder in its window. + @discardableResult + override open func becomeFirstResponder() -> Bool { + guard !isEditing && delegateAllowsEditingToBegin else { + return false + } + if canBecomeFirstResponder { + willBeginEditing() + } + let didBecomeFirstResponder = super.becomeFirstResponder() + if didBecomeFirstResponder { + didBeginEditing() + } else { + didCancelBeginEditing() + } + return didBecomeFirstResponder + } + + /// Notifies this object that it has been asked to relinquish its status as first responder in its window. + @discardableResult + override open func resignFirstResponder() -> Bool { + guard isEditing && shouldEndEditing else { + return false + } + let didResignFirstResponder = super.resignFirstResponder() + if didResignFirstResponder { + didEndEditing() + } + return didResignFirstResponder + } + + /// Copy the selected text. + /// + /// - Parameter sender: The object calling this method. + open override func copy(_ sender: Any?) { + if let selectedTextRange = selectedTextRange, let text = text(in: selectedTextRange) { + UIPasteboard.general.string = text + } + } + + /// Paste text from the pasteboard. + /// + /// - Parameter sender: The object calling this method. + open override func paste(_ sender: Any?) { + if let selectedTextRange = selectedTextRange, let string = UIPasteboard.general.string { + inputDelegate?.selectionWillChange(self) + let preparedText = prepareTextForInsertion(string) + replace(selectedTextRange, withText: preparedText) + inputDelegate?.selectionDidChange(self) + } + } + + /// Cut text to the pasteboard. + /// + /// - Parameter sender: The object calling this method. + open override func cut(_ sender: Any?) { + if let selectedTextRange = selectedTextRange, let text = text(in: selectedTextRange) { + UIPasteboard.general.string = text + replace(selectedTextRange, withText: "") + } + } + + /// Select all text in the text view. + /// + /// - Parameter sender: The object calling this method. + open override func selectAll(_ sender: Any?) { + notifyInputDelegateAboutSelectionChangeInLayoutSubviews = true + selectedRange = NSRange(location: 0, length: textViewController.stringView.string.length) + } + + /// Replace the selected range with the specified text. + /// + /// - Parameter obj: Text to replace the selected range with. + @objc func replace(_ obj: NSObject) { + /// When autocorrection is enabled and the user tap on a misspelled word, UITextInteraction will present + /// a UIMenuController with suggestions for the correct spelling of the word. Selecting a suggestion will + /// cause UITextInteraction to call the non-existing -replace(_:) function and pass an instance of the private + /// UITextReplacement type as parameter. We can't make autocorrection work properly without using private API. + if let replacementText = obj.value(forKey: "_repl" + "Ttnemeca".reversed() + "ext") as? String { + if let indexedRange = obj.value(forKey: "_r" + "gna".reversed() + "e") as? IndexedRange { + replace(indexedRange, withText: replacementText) + } + } + } + + /// Requests the receiving responder to enable or disable the specified command in the user interface. + /// - Parameters: + /// - action: A selector that identifies a method associated with a command. + /// - sender: The object calling this method. + /// - Returns: true if the command identified by action should be enabled or false if it should be disabled. + open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(copy(_:)) { + if let selectedTextRange = selectedTextRange { + return !selectedTextRange.isEmpty + } else { + return false + } + } else if action == #selector(cut(_:)) { + if let selectedTextRange = selectedTextRange { + return isEditing && !selectedTextRange.isEmpty + } else { + return false + } + } else if action == #selector(paste(_:)) { + return isEditing && UIPasteboard.general.hasStrings + } else if action == #selector(selectAll(_:)) { + return true + } else if action == #selector(replace(_:)) { + return true + } else if action == NSSelectorFromString("replaceTextInSelectedHighlightedRange") { + if let selectedRange = textViewController.selectedRange, let highlightedRange = textViewController.highlightedRange(for: selectedRange) { + return editorDelegate?.textView(self, canReplaceTextIn: highlightedRange) ?? false + } else { + return false + } + } else { + return super.canPerformAction(action, withSender: sender) + } + } + + /// Sets the current _state_ of the editor. The state contains the text to be displayed by the editor and + /// various additional information about the text that the editor needs to show the text. + /// + /// It is safe to create an instance of TextViewState in the background, and as such it can be + /// created before presenting the editor to the user, e.g. when opening the document from an instance of + /// UIDocumentBrowserViewController. + /// + /// This is the preferred way to initially set the text, language and theme on the TextView. + /// - Parameter state: The new state to be used by the editor. + /// - Parameter addUndoAction: Whether the state change can be undone. Defaults to false. + public func setState(_ state: TextViewState, addUndoAction: Bool = false) { + textViewController.setState(state, addUndoAction: addUndoAction) + contentSize = textViewController.contentSize + } + + /// Returns the row and column at the specified location in the text. + /// Common usages of this includes showing the line and column that the caret is currently located at. + /// - Parameter location: The location is relative to the first index in the string. + /// - Returns: The text location if the input location could be found in the string, otherwise nil. + public func textLocation(at location: Int) -> TextLocation? { + if let linePosition = textViewController.lineManager.linePosition(at: location) { + return TextLocation(linePosition) + } else { + return nil + } + } + + /// Returns the character location at the specified row and column. + /// - Parameter textLocation: The row and column in the text. + /// - Returns: The location if the input row and column could be found in the text, otherwise nil. + public func location(at textLocation: TextLocation) -> Int? { + let lineIndex = textLocation.lineNumber + guard lineIndex >= 0 && lineIndex < textViewController.lineManager.lineCount else { + return nil + } + let line = textViewController.lineManager.line(atRow: lineIndex) + guard textLocation.column >= 0 && textLocation.column <= line.data.totalLength else { + return nil + } + return line.location + textLocation.column + } + + /// Sets the language mode on a background thread. + /// + /// - Parameters: + /// - languageMode: The new language mode to be used by the editor. + /// - completion: Called when the content have been parsed or when parsing fails. + public func setLanguageMode(_ languageMode: LanguageMode, completion: ((Bool) -> Void)? = nil) { + textViewController.setLanguageMode(languageMode, completion: completion) + } + + /// Replaces the text in the specified matches. + /// - Parameters: + /// - batchReplaceSet: Set of ranges to replace with a text. + public func replaceText(in batchReplaceSet: BatchReplaceSet) { + // textInputView.replaceText(in: batchReplaceSet) + } + + /// Returns the syntax node at the specified location in the document. + /// + /// This can be used with character pairs to determine if a pair should be inserted or not. + /// For example, a character pair consisting of two quotes (") to surround a string, should probably not be + /// inserted when the quote is typed while the caret is already inside a string. + /// + /// This requires a language to be set on the editor. + /// - Parameter location: A location in the document. + /// - Returns: The syntax node at the location. + public func syntaxNode(at location: Int) -> SyntaxNode? { + textViewController.syntaxNode(at: location) + } + + /// Checks if the specified locations is within the indentation of the line. + /// + /// - Parameter location: A location in the document. + /// - Returns: True if the location is within the indentation of the line, otherwise false. + public func isIndentation(at location: Int) -> Bool { + textViewController.isIndentation(at: location) + } + + /// Decreases the indentation level of the selected lines. + public func shiftLeft() { + if let selectedRange = textViewController.selectedRange { + inputDelegate?.textWillChange(self) + textViewController.indentController.shiftLeft(in: selectedRange) + inputDelegate?.textDidChange(self) + } + } + + /// Increases the indentation level of the selected lines. + public func shiftRight() { + if let selectedRange = textViewController.selectedRange { + inputDelegate?.textWillChange(self) + textViewController.indentController.shiftRight(in: selectedRange) + inputDelegate?.textDidChange(self) + } + } + + /// Moves the selected lines up by one line. + /// + /// Calling this function has no effect when the selected lines include the first line in the text view. + public func moveSelectedLinesUp() { + textViewController.moveSelectedLinesUp() + } + + /// Moves the selected lines down by one line. + /// + /// Calling this function has no effect when the selected lines include the last line in the text view. + public func moveSelectedLinesDown() { + textViewController.moveSelectedLinesDown() + } + + /// Attempts to detect the indent strategy used in the document. This may return an unknown strategy even + /// when the document contains indentation. + public func detectIndentStrategy() -> DetectedIndentStrategy { + return textViewController.languageMode.detectIndentStrategy() + } + + /// Go to the beginning of the line at the specified index. + /// + /// - Parameter lineIndex: Index of line to navigate to. + /// - Parameter selection: The placement of the caret on the line. + /// - Returns: True if the text view could navigate to the specified line index, otherwise false. + @discardableResult + public func goToLine(_ lineIndex: Int, select selection: GoToLineSelection = .beginning) -> Bool { + textViewController.goToLine(lineIndex, select: selection) + } + + /// Search for the specified query. + /// + /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:)``. + /// + /// ```swift + /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) + /// let results = textView.search(for: query) + /// ``` + /// + /// - Parameter query: Query to find matches for. + /// - Returns: Results matching the query. + public func search(for query: SearchQuery) -> [SearchResult] { + let searchController = SearchController(stringView: textViewController.stringView) + searchController.delegate = self + return searchController.search(for: query) + } + + /// Search for the specified query and return results that take a replacement string into account. + /// + /// When searching for a regular expression this function will perform pattern matching and take the matched groups into account in the returned results. + /// + /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:replacingMatchesWith:)`` and how the returned search results can be used to perform a replace operation. + /// + /// ```swift + /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) + /// let results = textView.search(for: query, replacingMatchesWith: "bar") + /// let replacements = results.map { BatchReplaceSet.Replacement(range: $0.range, text: $0.replacementText) } + /// let batchReplaceSet = BatchReplaceSet(replacements: replacements) + /// textView.replaceText(in: batchReplaceSet) + /// ``` + /// + /// - Parameters: + /// - query: Query to find matches for. + /// - replacementString: String to replace matches with. Can refer to groups in a regular expression using $0, $1, $2 etc. + /// - Returns: Results matching the query. + public func search(for query: SearchQuery, replacingMatchesWith replacementString: String) -> [SearchReplaceResult] { + let searchController = SearchController(stringView: textViewController.stringView) + searchController.delegate = self + return searchController.search(for: query, replacingMatchesWith: replacementString) + } + + /// Returns a peek into the text view's underlying attributed string. + /// - Parameter range: Range of text to include in text view. The returned result may span a larger range than the one specified. + /// - Returns: Text preview containing the specified range. + public func textPreview(containing range: NSRange) -> TextPreview? { + textViewController.layoutManager.textPreview(containing: range) + } + + /// Selects a highlighted range behind the selected range if possible. + public func selectPreviousHighlightedRange() { + textViewController.highlightNavigationController.selectPreviousRange() + } + + /// Selects a highlighted range after the selected range if possible. + public func selectNextHighlightedRange() { + textViewController.highlightNavigationController.selectNextRange() + } + + /// Selects the highlighed range at the specified index. + /// - Parameter index: Index of highlighted range to select. + public func selectHighlightedRange(at index: Int) { + textViewController.highlightNavigationController.selectRange(at: index) + } + + /// Synchronously displays the visible lines. This can be used to immediately update the visible lines after setting the theme. Use with caution as redisplaying the visible lines can be a costly operation. + public func redisplayVisibleLines() { + textViewController.layoutManager.redisplayVisibleLines() + } + + /// Scrolls the text view to reveal the text in the specified range. + /// + /// The function will scroll the text view as little as possible while revealing as much as possible of the specified range. It is not guaranteed that the entire range is visible after performing the scroll. + /// + /// - Parameters: + /// - range: The range of text to scroll into view. + public func scrollRangeToVisible(_ range: NSRange) { + textViewController.scrollRangeToVisible(range) + } +} + +extension TextView { + var viewHierarchyContainsCaret: Bool { + return textSelectionView?.subviews.count == 1 + } + var textSelectionView: UIView? { + if let klass = NSClassFromString("UITextSelectionView") { + return subviews.first { $0.isKind(of: klass) } + } else { + return nil + } + } + + func handleTextSelectionChange() { + UIMenuController.shared.hideMenu(from: self) + textViewController.highlightNavigationController.selectedRange = textViewController.selectedRange + if isAutomaticScrollEnabled, let newRange = textViewController.selectedRange, newRange.length == 0 { + textViewController.scrollLocationToVisible(newRange.location) + } + editorDelegate?.textViewDidChangeSelection(self) + } + + func sendSelectionChangedToTextSelectionView() { + // The only way I've found to get the selection change to be reflected properly while still supporting Korean, Chinese, and deleting words with Option+Backspace is to call a private API in some cases. However, as pointed out by Alexander Blach in the following PR, there is another workaround to the issue. + // When passing nil to the input delete, the text selection is update but the text input ignores it. + // Even the Swift Playgrounds app does not get this right for all languages in all cases, so there seems to be some workarounds needed to due bugs in internal classes in UIKit that communicate with instances of UITextInput. + inputDelegate?.selectionDidChange(nil) + } + + func removeAndAddEditableTextInteraction() { + // There seems to be a bug in UITextInput (or UITextInteraction?) where updating the markedTextRange of a UITextInput will cause the caret to disappear. Removing the editable text interaction and adding it back will work around this issue. + DispatchQueue.main.async { + if !self.viewHierarchyContainsCaret && self.editableTextInteraction.view != nil { + self.removeInteraction(self.editableTextInteraction) + self.addInteraction(self.editableTextInteraction) + } + } + } +} + +private extension TextView { + private func willBeginEditing() { + guard isEditable else { + return + } + textViewController.isEditing = !isPerformingNonEditableTextInteraction + // If a developer is programmatically calling becomeFirstResponder() then we might not have a selected range. + // We set the selectedRange instead of the selectedTextRange to avoid invoking any delegates. + if textViewController.selectedRange == nil && !isPerformingNonEditableTextInteraction { + textViewController.selectedRange = NSRange(location: 0, length: 0) + } + // Ensure selection is laid out without animation. + UIView.performWithoutAnimation { + layoutIfNeeded() + } + // The editable interaction must be installed early in the -becomeFirstResponder() call + if !isPerformingNonEditableTextInteraction { + installEditableInteraction() + } + } + + private func didBeginEditing() { + if !isPerformingNonEditableTextInteraction { + editorDelegate?.textViewDidBeginEditing(self) + } + } + + private func didCancelBeginEditing() { + // This is called in the case where: + // 1. The view is the first responder. + // 2. A view is presented modally on top of the editor. + // 3. The modally presented view is dismissed. + // 4. The responder chain attempts to make the text view first responder again but super.becomeFirstResponder() returns false. + textViewController.isEditing = false + installNonEditableInteraction() + } + + private func didEndEditing() { + textViewController.isEditing = false + installNonEditableInteraction() + editorDelegate?.textViewDidEndEditing(self) + } + + private func updateCaretColor() { + // Removing the UITextSelectionView and re-adding it forces it to query the insertion point color. + if let textSelectionView = textSelectionView { + textSelectionView.removeFromSuperview() + addSubview(textSelectionView) + } + } + + private func installEditableInteraction() { + if editableTextInteraction.view == nil { + isInputAccessoryViewEnabled = true + removeInteraction(nonEditableTextInteraction) + addInteraction(editableTextInteraction) + } + } + + private func installNonEditableInteraction() { + if nonEditableTextInteraction.view == nil { + isInputAccessoryViewEnabled = false + removeInteraction(editableTextInteraction) + addInteraction(nonEditableTextInteraction) + for gestureRecognizer in nonEditableTextInteraction.gesturesForFailureRequirements { + gestureRecognizer.require(toFail: tapGestureRecognizer) + } + } + } + + @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { + guard isSelectable, gestureRecognizer.state == .ended else { + return + } + let point = gestureRecognizer.location(in: self) + let oldSelectedRange = selectedRange + if let index = textViewController.layoutManager.closestIndex(to: point) { + selectedRange = NSRange(location: index, length: 0) + } + if selectedRange != oldSelectedRange { + layoutIfNeeded() + } + installEditableInteraction() + becomeFirstResponder() + } + + @objc private func handleTextRangeAdjustmentPan(_ gestureRecognizer: UIPanGestureRecognizer) { + // This function scroll the text view when the selected range is adjusted. + if gestureRecognizer.state == .began { + previousSelectedRangeDuringGestureHandling = selectedRange + } else if gestureRecognizer.state == .changed, let previousSelectedRange = previousSelectedRangeDuringGestureHandling { + if selectedRange.lowerBound != previousSelectedRange.lowerBound { + // User is adjusting the lower bound (location) of the selected range. + textViewController.scrollLocationToVisible(selectedRange.lowerBound) + } else if selectedRange.upperBound != previousSelectedRange.upperBound { + // User is adjusting the upper bound (length) of the selected range. + textViewController.scrollLocationToVisible(selectedRange.upperBound) + } + previousSelectedRangeDuringGestureHandling = selectedRange + } + } + + @objc private func replaceTextInSelectedHighlightedRange() { + if let selectedRange = textViewController.selectedRange, let highlightedRange = textViewController.highlightedRange(for: selectedRange) { + editorDelegate?.textView(self, replaceTextIn: highlightedRange) + } + } +} + +//// MARK: - TextInputViewDelegate +//extension TextView: TextInputViewDelegate { +// func textInputViewDidChange(_ view: TextInputView) { +// if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { +// scrollLocationToVisible(newRange.location) +// } +// editorDelegate?.textViewDidChange(self) +// } +//} + +// MARK: - SearchControllerDelegate +extension TextView: SearchControllerDelegate { + func searchController(_ searchController: SearchController, linePositionAt location: Int) -> LinePosition? { + textViewController.lineManager.linePosition(at: location) + } +} + +// MARK: - UIGestureRecognizerDelegate +extension TextView: UIGestureRecognizerDelegate { + override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer === tapGestureRecognizer { + return !isEditing && !isDragging && !isDecelerating && delegateAllowsEditingToBegin + } else { + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } + } + + public func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + if let klass = NSClassFromString("UITextRangeAdjustmentGestureRecognizer") { + if !textRangeAdjustmentGestureRecognizers.contains(otherGestureRecognizer) && otherGestureRecognizer.isKind(of: klass) { + otherGestureRecognizer.addTarget(self, action: #selector(handleTextRangeAdjustmentPan(_:))) + textRangeAdjustmentGestureRecognizers.insert(otherGestureRecognizer) + } + } + return gestureRecognizer !== panGestureRecognizer + } +} + +// MARK: - KeyboardObserverDelegate +extension TextView: KeyboardObserverDelegate { + func keyboardObserver( + _ keyboardObserver: KeyboardObserver, + keyboardWillShowWithHeight keyboardHeight: CGFloat, + animation: KeyboardObserver.Animation? + ) { + if isAutomaticScrollEnabled, let newRange = textViewController.selectedRange, newRange.length == 0 { + textViewController.scrollRangeToVisible(newRange) + } + } +} + +// MARK: - UITextInteractionDelegate +extension TextView: UITextInteractionDelegate { + public func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { + if interaction.textInteractionMode == .editable { + return isEditable + } else if interaction.textInteractionMode == .nonEditable { + // The private UITextLoupeInteraction and UITextNonEditableInteractionclass will end up in this case. The latter is likely created from UITextInteraction(for: .nonEditable) but we want to disable both when selection is disabled. + return isSelectable + } else { + return true + } + } + + public func interactionWillBegin(_ interaction: UITextInteraction) { + if interaction.textInteractionMode == .nonEditable { + // When long-pressing our instance of UITextInput, the UITextInteraction will make the text input first responder. + // In this case the user wants to select text in the text view but not start editing, so we set a flag that tells us + // that we should not install editable text interaction in this case. + isPerformingNonEditableTextInteraction = true + } + } + + public func interactionDidEnd(_ interaction: UITextInteraction) { + if interaction.textInteractionMode == .nonEditable { + isPerformingNonEditableTextInteraction = false + } + } +} + +// MARK: - EditMenuControllerDelegate +extension TextView: EditMenuControllerDelegate { + func editMenuController(_ controller: EditMenuController, caretRectAt location: Int) -> CGRect { + textViewController.caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: false) + } + + func editMenuControllerShouldReplaceText(_ controller: EditMenuController) { + replaceTextInSelectedHighlightedRange() + } + + func editMenuController(_ controller: EditMenuController, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { + return editorDelegate?.textView(self, canReplaceTextIn: highlightedRange) ?? false + } + + func editMenuController(_ controller: EditMenuController, highlightedRangeFor range: NSRange) -> HighlightedRange? { + highlightedRanges.first { $0.range == range } + } + + func selectedRange(for controller: EditMenuController) -> NSRange? { + selectedRange + } +} + +// MARK: - HighlightNavigationControllerDelegate +extension TextView: HighlightNavigationControllerDelegate { + func highlightNavigationController( + _ controller: HighlightNavigationController, + shouldNavigateTo highlightNavigationRange: HighlightNavigationRange + ) { + let range = highlightNavigationRange.range + scrollRangeToVisible(range) + selectedTextRange = IndexedRange(range) + _ = becomeFirstResponder() + #if os(iOS) + if showMenuAfterNavigatingToHighlightedRange { + editMenuController.presentEditMenu(from: self, forTextIn: range) + } + #endif + switch highlightNavigationRange.loopMode { + case .previousGoesToLast: + editorDelegate?.textViewDidLoopToLastHighlightedRange(self) + case .nextGoesToFirst: + editorDelegate?.textViewDidLoopToFirstHighlightedRange(self) + case .disabled: + break + } + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift new file mode 100644 index 000000000..248a28e18 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift @@ -0,0 +1,42 @@ +import Foundation + +extension TextViewController { + var contentSize: CGSize { + let horizontalOverscrollLength = max(textView.frame.width * horizontalOverscrollFactor, 0) + let verticalOverscrollLength = max(textView.frame.height * verticalOverscrollFactor, 0) + let baseContentSize = contentSizeService.contentSize + let width = isLineWrappingEnabled ? baseContentSize.width : baseContentSize.width + horizontalOverscrollLength + let height = baseContentSize.height + verticalOverscrollLength + return CGSize(width: width, height: height) + } + + func invalidateContentSizeIfNeeded() { + if textView.contentSize != contentSize { + hasPendingContentSizeUpdate = true + handleContentSizeUpdateIfNeeded() + } + } + + func handleContentSizeUpdateIfNeeded() { + guard hasPendingContentSizeUpdate else { + return + } + // We don't want to update the content size when the scroll view is "bouncing" near the gutter, + // or at the end of a line since it causes flickering when updating the content size while scrolling. + // However, we do allow updating the content size if the text view is scrolled far enough on + // the y-axis as that means it will soon run out of text to display. + let isBouncingAtGutter = textView.contentOffset.x < -textView.contentInset.left + let isBouncingAtLineEnd = textView.contentOffset.x > textView.contentSize.width - textView.frame.size.width + textView.contentInset.right + let isBouncingHorizontally = isBouncingAtGutter || isBouncingAtLineEnd + let isCriticalUpdate = textView.contentOffset.y > textView.contentSize.height - textView.frame.height * 1.5 + let isScrolling = textView.isDragging || textView.isDecelerating + guard !isBouncingHorizontally || isCriticalUpdate || !isScrolling else { + return + } + hasPendingContentSizeUpdate = false + let oldContentOffset = textView.contentOffset + textView.contentSize = contentSize + textView.contentOffset = oldContentOffset + textView.setNeedsLayout() + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift new file mode 100644 index 000000000..f8da68398 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift @@ -0,0 +1,140 @@ +import Foundation + +extension TextViewController { + func text(in range: NSRange) -> String? { + stringView.substring(in: range.nonNegativeLength) + } + + func replaceText( + in range: NSRange, + with newString: String, + selectedRangeAfterUndo: NSRange? = nil, + undoActionName: String = L10n.Undo.ActionName.typing + ) { + let nsNewString = newString as NSString + let currentText = text(in: range) ?? "" + let newRange = NSRange(location: range.location, length: nsNewString.length) + addUndoOperation(replacing: newRange, withText: currentText, selectedRangeAfterUndo: selectedRangeAfterUndo, actionName: undoActionName) + selectedRange = NSRange(location: newRange.upperBound, length: 0) + let textEditHelper = TextEditHelper(stringView: stringView, lineManager: lineManager, lineEndings: lineEndings) + let textEditResult = textEditHelper.replaceText(in: range, with: newString) + let textChange = textEditResult.textChange + let lineChangeSet = textEditResult.lineChangeSet + let languageModeLineChangeSet = languageMode.textDidChange(textChange) + lineChangeSet.union(with: languageModeLineChangeSet) + applyLineChangesToLayoutManager(lineChangeSet) + let updatedTextEditResult = TextEditResult(textChange: textChange, lineChangeSet: lineChangeSet) + if isAutomaticScrollEnabled, let newRange = selectedRange, newRange.length == 0 { + scrollLocationToVisible(newRange.location) + } + textView.editorDelegate?.textViewDidChange(textView) + if updatedTextEditResult.didAddOrRemoveLines { + invalidateContentSizeIfNeeded() + } + } + + func rangeForDeletingText(in range: NSRange) -> NSRange { + var resultingRange = range + if range.length == 1, let indentRange = indentController.indentRangeInFrontOfLocation(range.upperBound) { + resultingRange = indentRange + } else { + resultingRange = stringView.string.customRangeOfComposedCharacterSequences(for: range) + } + // If deleting the leading component of a character pair we may also expand the range to delete the trailing component. + if characterPairTrailingComponentDeletionMode == .immediatelyFollowingLeadingComponent + && maximumLeadingCharacterPairComponentLength > 0 + && resultingRange.length <= maximumLeadingCharacterPairComponentLength { + let stringToDelete = stringView.substring(in: resultingRange) + if let characterPair = characterPairs.first(where: { $0.leading == stringToDelete }) { + let trailingComponentLength = characterPair.trailing.utf16.count + let trailingComponentRange = NSRange(location: resultingRange.upperBound, length: trailingComponentLength) + if stringView.substring(in: trailingComponentRange) == characterPair.trailing { + let deleteRange = trailingComponentRange.upperBound - resultingRange.lowerBound + resultingRange = NSRange(location: resultingRange.lowerBound, length: deleteRange) + } + } + } + return resultingRange + } + + func prepareTextForInsertion(_ text: String) -> String { + // Ensure all line endings match our preferred line endings. + var preparedText = text + let lineEndingsToReplace: [LineEnding] = [.crlf, .cr, .lf].filter { $0 != lineEndings } + for lineEnding in lineEndingsToReplace { + preparedText = preparedText.replacingOccurrences(of: lineEnding.symbol, with: lineEndings.symbol) + } + return preparedText + } + + func shouldChangeText(in range: NSRange, replacementText text: String) -> Bool { + if skipInsertComponentCheck { + // UIKit is inserting text to combine characters, for example to combine two Korean characters into one, and we do not want to interfere with that. + return textView.editorDelegate?.textView(textView, shouldChangeTextIn: range, replacementText: text) ?? true + } else if let characterPair = characterPairs.first(where: { $0.trailing == text }), + skipInsertingTrailingComponent(of: characterPair, in: range) { + return false + } else if let characterPair = characterPairs.first(where: { $0.leading == text }), insertLeadingComponent(of: characterPair, in: range) { + return false + } else { + return delegateAllowsChangeText(in: range, withReplacementText: text) + } + } +} + +private extension TextViewController { + private var skipInsertComponentCheck: Bool { + #if os(iOS) + return textView.isRestoringPreviouslyDeletedText + #else + return false + #endif + } + + private func delegateAllowsChangeText(in range: NSRange, withReplacementText replacementText: String) -> Bool { + textView.editorDelegate?.textView(textView, shouldChangeTextIn: range, replacementText: text) ?? true + } + + private func insertLeadingComponent(of characterPair: CharacterPair, in range: NSRange) -> Bool { + let shouldInsertCharacterPair = textView.editorDelegate?.textView(textView, shouldInsert: characterPair, in: range) ?? true + guard shouldInsertCharacterPair else { + return false + } + guard let selectedRange = selectedRange else { + return false + } + if selectedRange.length == 0 { + insertText(characterPair.leading + characterPair.trailing) + self.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: 0) + return true + } else if let text = text(in: selectedRange) { + let modifiedText = characterPair.leading + text + characterPair.trailing + let indexedRange = IndexedRange(selectedRange) + replace(indexedRange, withText: modifiedText) + self.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: range.length) + return true + } else { + return false + } + } + + private func skipInsertingTrailingComponent(of characterPair: CharacterPair, in range: NSRange) -> Bool { + // When typing the trailing component of a character pair, e.g. ) or } and the cursor is just in front of that character, + // the delegate is asked whether the text view should skip inserting that character. If the character is skipped, + // then the caret is moved after the trailing character component. + let followingTextRange = NSRange(location: range.location + range.length, length: characterPair.trailing.count) + let followingText = text(in: followingTextRange) + guard followingText == characterPair.trailing else { + return false + } + let shouldSkip = textView.editorDelegate?.textView(textView, shouldSkipTrailingComponentOf: characterPair, in: range) ?? true + guard shouldSkip else { + return false + } + if let selectedRange = selectedRange { + let offset = characterPair.trailing.count + self.selectedRange = NSRange(location: selectedRange.location + offset, length: 0) + } + return true + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+GoToLine.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+GoToLine.swift new file mode 100644 index 000000000..c5ce1c558 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+GoToLine.swift @@ -0,0 +1,27 @@ +import Foundation + +extension TextViewController { + public func goToLine(_ lineIndex: Int, select selection: GoToLineSelection = .beginning) -> Bool { + guard lineIndex >= 0 && lineIndex < lineManager.lineCount else { + return false + } + // I'm not exactly sure why this is necessary but if the text view is the first responder as we jump + // to the line and we don't resign the first responder first, the caret will disappear after we have + // jumped to the specified line. + textView.resignFirstResponder() + textView.becomeFirstResponder() + let line = lineManager.line(atRow: lineIndex) + layoutManager.layoutLines(toLocation: line.location) + scrollLocationToVisible(line.location) + textView.layoutIfNeeded() + switch selection { + case .beginning: + selectedRange = NSRange(location: line.location, length: 0) + case .end: + selectedRange = NSRange(location: line.data.length, length: line.data.length) + case .line: + selectedRange = NSRange(location: line.location, length: line.data.length) + } + return true + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Indentation.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Indentation.swift new file mode 100644 index 000000000..8ffb12c03 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Indentation.swift @@ -0,0 +1,16 @@ +import Foundation + +extension TextViewController { + func isIndentation(at location: Int) -> Bool { + guard let line = lineManager.line(containingCharacterAt: location) else { + return false + } + let localLocation = location - line.location + guard localLocation >= 0 else { + return false + } + let indentLevel = languageMode.currentIndentLevel(of: line, using: indentStrategy) + let indentString = indentStrategy.string(indentLevel: indentLevel) + return localLocation <= indentString.utf16.count + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift new file mode 100644 index 000000000..8d7d8736c --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift @@ -0,0 +1,64 @@ +import Foundation + +extension TextViewController { + func performFullLayout() { + invalidateLines() + layoutManager.setNeedsLayout() + layoutManager.layoutIfNeeded() + } + + func performFullLayoutIfNeeded() { + if hasPendingFullLayout && textView.window != nil { + hasPendingFullLayout = false + performFullLayout() + } + } + + func layout() { + layoutManager.layoutIfNeeded() + layoutManager.layoutLineSelectionIfNeeded() + layoutPageGuideIfNeeded() + } + + func invalidateLines() { + for lineController in lineControllerStorage { + lineController.lineFragmentHeightMultiplier = lineHeightMultiplier + lineController.tabWidth = indentController.tabWidth + lineController.kern = kern + lineController.lineBreakMode = lineBreakMode + lineController.invalidateSyntaxHighlighting() + } + } + + func applyLineChangesToLayoutManager(_ lineChangeSet: LineChangeSet) { + let didAddOrRemoveLines = !lineChangeSet.insertedLines.isEmpty || !lineChangeSet.removedLines.isEmpty + if didAddOrRemoveLines { + contentSizeService.invalidateContentSize() + for removedLine in lineChangeSet.removedLines { + lineControllerStorage.removeLineController(withID: removedLine.id) + contentSizeService.removeLine(withID: removedLine.id) + } + } + let editedLineIDs = Set(lineChangeSet.editedLines.map(\.id)) + layoutManager.redisplayLines(withIDs: editedLineIDs) + if didAddOrRemoveLines { + gutterWidthService.invalidateLineNumberWidth() + } + layoutManager.setNeedsLayout() + layoutManager.layoutIfNeeded() + } + + func layoutPageGuideIfNeeded() { + guard showPageGuide else { + return + } + // The width extension is used to make the page guide look "attached" to the right hand side, even when the scroll view bouncing on the right side. + let maxContentOffsetX = contentSizeService.contentWidth - viewport.width + let widthExtension = max(ceil(viewport.minX - maxContentOffsetX), 0) + let xPosition = gutterWidthService.gutterWidth + textContainerInset.left + pageGuideController.columnOffset + let width = max(contentSizeService.contentWidth - xPosition + widthExtension, 0) + let origin = CGPoint(x: xPosition, y: viewport.minY) + let pageGuideSize = CGSize(width: width, height: viewport.height) + pageGuideController.guideView.frame = CGRect(origin: origin, size: pageGuideSize) + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+MoveLines.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+MoveLines.swift new file mode 100644 index 000000000..4833054cd --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+MoveLines.swift @@ -0,0 +1,32 @@ +import Foundation + +extension TextViewController { + func moveSelectedLinesUp() { + moveSelectedLine(byOffset: -1, undoActionName: L10n.Undo.ActionName.moveLinesUp) + } + + func moveSelectedLinesDown() { + moveSelectedLine(byOffset: 1, undoActionName: L10n.Undo.ActionName.moveLinesDown) + } +} + +private extension TextViewController { + private func moveSelectedLine(byOffset lineOffset: Int, undoActionName: String) { + guard let oldSelectedRange = selectedRange else { + return + } + let moveLinesService = MoveLinesService(stringView: stringView, lineManager: lineManager, lineEndingSymbol: lineEndings.symbol) + guard let operation = moveLinesService.operationForMovingLines(in: oldSelectedRange, byOffset: lineOffset) else { + return + } + timedUndoManager.endUndoGrouping() + timedUndoManager.beginUndoGrouping() + replaceText(in: operation.removeRange, with: "", undoActionName: undoActionName) + replaceText(in: operation.replacementRange, with: operation.replacementString, undoActionName: undoActionName) + #if os(iOS) + textView.notifyInputDelegateAboutSelectionChangeInLayoutSubviews = true + #endif + selectedRange = operation.selectedRange + timedUndoManager.endUndoGrouping() + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift new file mode 100644 index 000000000..0c31b3f6c --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift @@ -0,0 +1,65 @@ +import Foundation + + extension TextViewController { + func scrollRangeToVisible(_ range: NSRange) { + layoutManager.layoutLines(toLocation: range.upperBound) + justScrollRangeToVisible(range) + } + + func scrollLocationToVisible(_ location: Int) { + let range = NSRange(location: location, length: 0) + justScrollRangeToVisible(range) + } +} + +private extension TextViewController { + private func justScrollRangeToVisible(_ range: NSRange) { + let lowerBoundRect = caretRect(at: range.lowerBound) + let upperBoundRect = range.length == 0 ? lowerBoundRect : caretRect(at: range.upperBound) + let rectMinX = min(lowerBoundRect.minX, upperBoundRect.minX) + let rectMaxX = max(lowerBoundRect.maxX, upperBoundRect.maxX) + let rectMinY = min(lowerBoundRect.minY, upperBoundRect.minY) + let rectMaxY = max(lowerBoundRect.maxY, upperBoundRect.maxY) + let rect = CGRect(x: rectMinX, y: rectMinY, width: rectMaxX - rectMinX, height: rectMaxY - rectMinY) + textView.contentOffset = contentOffsetForScrollingToVisibleRect(rect) + } + + private func caretRect(at location: Int) -> CGRect { + caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: true) + } + + /// Computes a content offset to scroll to in order to reveal the specified rectangle. + /// + /// The function will return a rectangle that scrolls the text view a minimum amount while revealing as much as possible of the rectangle. It is not guaranteed that the entire rectangle can be revealed. + /// - Parameter rect: The rectangle to reveal. + /// - Returns: The content offset to scroll to. + private func contentOffsetForScrollingToVisibleRect(_ rect: CGRect) -> CGPoint { + // Create the viewport: a rectangle containing the content that is visible to the user. + var viewport = CGRect(x: textView.contentOffset.x, y: textView.contentOffset.y, width: textView.frame.width, height: textView.frame.height) + viewport.origin.y += textView.adjustedContentInset.top + viewport.origin.x += textView.adjustedContentInset.left + gutterWidth + viewport.size.width -= textView.adjustedContentInset.left + textView.adjustedContentInset.right + gutterWidth + viewport.size.height -= textView.adjustedContentInset.top + textView.adjustedContentInset.bottom + // Construct the best possible content offset. + var newContentOffset = textView.contentOffset + if rect.minX < viewport.minX { + newContentOffset.x -= viewport.minX - rect.minX + } else if rect.maxX > viewport.maxX && rect.width <= viewport.width { + // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. + newContentOffset.x += rect.maxX - viewport.maxX + } else if rect.maxX > viewport.maxX { + newContentOffset.x += rect.minX + } + if rect.minY < viewport.minY { + newContentOffset.y -= viewport.minY - rect.minY + } else if rect.maxY > viewport.maxY && rect.height <= viewport.height { + // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. + newContentOffset.y += rect.maxY - viewport.maxY + } else if rect.maxY > viewport.maxY { + newContentOffset.y += rect.minY + } + let cappedXOffset = min(max(newContentOffset.x, textView.minimumContentOffset.x), textView.maximumContentOffset.x) + let cappedYOffset = min(max(newContentOffset.y, textView.minimumContentOffset.y), textView.maximumContentOffset.y) + return CGPoint(x: cappedXOffset, y: cappedYOffset) + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift new file mode 100644 index 000000000..4b2973737 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift @@ -0,0 +1,10 @@ +import Foundation + +extension TextViewController { + func safeSelectionRange(from range: NSRange) -> NSRange { + let stringLength = stringView.string.length + let cappedLocation = min(max(range.location, 0), stringLength) + let cappedLength = min(max(range.length, 0), stringLength - cappedLocation) + return NSRange(location: cappedLocation, length: cappedLength) + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Syntax.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Syntax.swift new file mode 100644 index 000000000..de811657c --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Syntax.swift @@ -0,0 +1,11 @@ +import Foundation + +extension TextViewController { + func syntaxNode(at location: Int) -> SyntaxNode? { + if let linePosition = lineManager.linePosition(at: location) { + return languageMode.syntaxNode(at: linePosition) + } else { + return nil + } + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift new file mode 100644 index 000000000..c23ec27ba --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift @@ -0,0 +1,25 @@ +import Foundation + +extension TextViewController { + func addUndoOperation( + replacing range: NSRange, + withText text: String, + selectedRangeAfterUndo: NSRange? = nil, + actionName: String = L10n.Undo.ActionName.typing + ) { + let oldSelectedRange = selectedRangeAfterUndo ?? selectedRange + timedUndoManager.beginUndoGrouping() + timedUndoManager.setActionName(actionName) + timedUndoManager.registerUndo(withTarget: self) { textViewController in + #if os(iOS) + textViewController.textView.inputDelegate?.selectionWillChange(textViewController.textView) + #endif + textViewController.replaceText(in: range, with: text) + textViewController.selectedRange = oldSelectedRange + #if os(iOS) + textViewController.textView.handleTextSelectionChange() + textViewController.textView.inputDelegate?.selectionDidChange(textViewController.textView) + #endif + } + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift new file mode 100644 index 000000000..d972bb4fc --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -0,0 +1,747 @@ +import Combine +import Foundation + +final class TextViewController { + var textView: TextView { + get { + if let textView = _textView { + return textView + } else { + fatalError("Text view has been deallocated or has not been assigned") + } + } + set { + _textView = newValue + } + } + private weak var _textView: TextView? + var selectedRange: NSRange? { + didSet { + if selectedRange != oldValue { + layoutManager.selectedRange = selectedRange + layoutManager.setNeedsLayoutLineSelection() + textView.setNeedsLayout() + } + } + } + var markedRange: NSRange? + var isEditing = false { + didSet { + if isEditing != oldValue { + layoutManager.isEditing = isEditing + } + } + } + var isEditable = true { + didSet { + if isEditable != oldValue && !isEditable && isEditing { + textView.resignFirstResponder() + isEditing = false + textView.editorDelegate?.textViewDidEndEditing(textView) + } + } + } + var isSelectable = true { + didSet { + if isSelectable != oldValue { + textView.isUserInteractionEnabled = isSelectable + if !isSelectable && isEditing { + textView.resignFirstResponder() + selectedRange = nil + #if os(iOS) + textView.handleTextSelectionChange() + #endif + isEditing = false + textView.editorDelegate?.textViewDidEndEditing(textView) + } + } + } + } + var viewport: CGRect { + get { + return layoutManager.viewport + } + set { + if newValue != layoutManager.viewport { + layoutManager.viewport = newValue + layoutManager.setNeedsLayout() + textView.setNeedsLayout() + } + } + } + var text: String { + get { + return stringView.string as String + } + set { + let nsString = newValue as NSString + if nsString != stringView.string { + stringView.string = nsString + languageMode.parse(nsString) + lineManager.rebuild() + if let oldSelectedRange = selectedRange { + #if os(iOS) + textView.inputDelegate?.selectionWillChange(textView) + #endif + selectedRange = safeSelectionRange(from: oldSelectedRange) + #if os(iOS) + textView.inputDelegate?.selectionDidChange(textView) + #endif + } + contentSizeService.invalidateContentSize() + gutterWidthService.invalidateLineNumberWidth() + invalidateLines() + layoutManager.setNeedsLayout() + layoutManager.layoutIfNeeded() + if !preserveUndoStackWhenSettingString { + timedUndoManager.removeAllActions() + } + } + } + } + var hasPendingContentSizeUpdate = false + var scrollViewWidth: CGFloat = 0 { + didSet { + if scrollViewWidth != oldValue { + contentSizeService.scrollViewWidth = scrollViewWidth + layoutManager.scrollViewWidth = scrollViewWidth + if isLineWrappingEnabled { + invalidateLines() + } + } + } + } + var safeAreaInsets: MultiPlatformEdgeInsets = .zero { + didSet { + if safeAreaInsets != oldValue { + layoutManager.safeAreaInsets = safeAreaInsets + } + } + } + + private(set) var stringView = StringView() { + didSet { + if stringView !== oldValue { + caretRectService.stringView = stringView + lineManager.stringView = stringView + lineControllerFactory.stringView = stringView + lineControllerStorage.stringView = stringView + layoutManager.stringView = stringView + indentController.stringView = stringView + lineMovementController.stringView = stringView + } + } + } + let invisibleCharacterConfiguration = InvisibleCharacterConfiguration() + private(set) var lineManager: LineManager { + didSet { + if lineManager !== oldValue { + indentController.lineManager = lineManager + lineMovementController.lineManager = lineManager + gutterWidthService.lineManager = lineManager + contentSizeService.lineManager = lineManager + caretRectService.lineManager = lineManager + selectionRectService.lineManager = lineManager + highlightService.lineManager = lineManager + } + } + } + let highlightService: HighlightService + let lineControllerFactory: LineControllerFactory + let lineControllerStorage: LineControllerStorage + let gutterWidthService: GutterWidthService + let contentSizeService: ContentSizeService + let caretRectService: CaretRectService + let selectionRectService: SelectionRectService + let layoutManager: LayoutManager + let indentController: IndentController + let lineMovementController: LineMovementController + let pageGuideController = PageGuideController() + let highlightNavigationController = HighlightNavigationController() + let timedUndoManager = TimedUndoManager() + + var languageMode: InternalLanguageMode = PlainTextInternalLanguageMode() { + didSet { + if languageMode !== oldValue { + indentController.languageMode = languageMode + if let treeSitterLanguageMode = languageMode as? TreeSitterInternalLanguageMode { + treeSitterLanguageMode.delegate = self + } + } + } + } + var lineEndings: LineEnding = .lf + var theme: Theme = DefaultTheme() { + didSet { + applyThemeToChildren() + } + } + var characterPairs: [CharacterPair] = [] { + didSet { + maximumLeadingCharacterPairComponentLength = characterPairs.map(\.leading.utf16.count).max() ?? 0 + } + } + var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode = .disabled + var showLineNumbers = false { + didSet { + if showLineNumbers != oldValue { + #if os(iOS) + textView.inputDelegate?.selectionWillChange(textView) + #endif + caretRectService.showLineNumbers = showLineNumbers + gutterWidthService.showLineNumbers = showLineNumbers + layoutManager.showLineNumbers = showLineNumbers + layoutManager.setNeedsLayout() + textView.setNeedsLayout() + #if os(iOS) + textView.inputDelegate?.selectionDidChange(textView) + #endif + } + } + } + var lineSelectionDisplayType: LineSelectionDisplayType { + get { + return layoutManager.lineSelectionDisplayType + } + set { + layoutManager.lineSelectionDisplayType = newValue + } + } + var showTabs: Bool { + get { + return invisibleCharacterConfiguration.showTabs + } + set { + if newValue != invisibleCharacterConfiguration.showTabs { + invisibleCharacterConfiguration.showTabs = newValue + layoutManager.setNeedsDisplayOnLines() + } + } + } + var showSpaces: Bool { + get { + return invisibleCharacterConfiguration.showSpaces + } + set { + if newValue != invisibleCharacterConfiguration.showSpaces { + invisibleCharacterConfiguration.showSpaces = newValue + layoutManager.setNeedsDisplayOnLines() + } + } + } + var showNonBreakingSpaces: Bool { + get { + return invisibleCharacterConfiguration.showNonBreakingSpaces + } + set { + if newValue != invisibleCharacterConfiguration.showNonBreakingSpaces { + invisibleCharacterConfiguration.showNonBreakingSpaces = newValue + layoutManager.setNeedsDisplayOnLines() + } + } + } + var showLineBreaks: Bool { + get { + return invisibleCharacterConfiguration.showLineBreaks + } + set { + if newValue != invisibleCharacterConfiguration.showLineBreaks { + invisibleCharacterConfiguration.showLineBreaks = newValue + invalidateLines() + layoutManager.setNeedsLayout() + layoutManager.setNeedsDisplayOnLines() + textView.setNeedsLayout() + } + } + } + var showSoftLineBreaks: Bool { + get { + return invisibleCharacterConfiguration.showSoftLineBreaks + } + set { + if newValue != invisibleCharacterConfiguration.showSoftLineBreaks { + invisibleCharacterConfiguration.showSoftLineBreaks = newValue + invalidateLines() + layoutManager.setNeedsLayout() + layoutManager.setNeedsDisplayOnLines() + textView.setNeedsLayout() + } + } + } + var tabSymbol: String { + get { + return invisibleCharacterConfiguration.tabSymbol + } + set { + if newValue != invisibleCharacterConfiguration.tabSymbol { + invisibleCharacterConfiguration.tabSymbol = newValue + layoutManager.setNeedsDisplayOnLines() + } + } + } + var spaceSymbol: String { + get { + return invisibleCharacterConfiguration.spaceSymbol + } + set { + if newValue != invisibleCharacterConfiguration.spaceSymbol { + invisibleCharacterConfiguration.spaceSymbol = newValue + layoutManager.setNeedsDisplayOnLines() + } + } + } + var nonBreakingSpaceSymbol: String { + get { + return invisibleCharacterConfiguration.nonBreakingSpaceSymbol + } + set { + if newValue != invisibleCharacterConfiguration.nonBreakingSpaceSymbol { + invisibleCharacterConfiguration.nonBreakingSpaceSymbol = newValue + layoutManager.setNeedsDisplayOnLines() + } + } + } + var lineBreakSymbol: String { + get { + return invisibleCharacterConfiguration.lineBreakSymbol + } + set { + if newValue != invisibleCharacterConfiguration.lineBreakSymbol { + invisibleCharacterConfiguration.lineBreakSymbol = newValue + layoutManager.setNeedsDisplayOnLines() + } + } + } + var softLineBreakSymbol: String { + get { + return invisibleCharacterConfiguration.softLineBreakSymbol + } + set { + if newValue != invisibleCharacterConfiguration.softLineBreakSymbol { + invisibleCharacterConfiguration.softLineBreakSymbol = newValue + layoutManager.setNeedsDisplayOnLines() + } + } + } + var indentStrategy: IndentStrategy = .tab(length: 2) { + didSet { + if indentStrategy != oldValue { + indentController.indentStrategy = indentStrategy + layoutManager.setNeedsLayout() + textView.setNeedsLayout() + textView.layoutIfNeeded() + } + } + } + var gutterLeadingPadding: CGFloat = 3 { + didSet { + if gutterLeadingPadding != oldValue { + gutterWidthService.gutterLeadingPadding = gutterLeadingPadding + layoutManager.setNeedsLayout() + textView.setNeedsLayout() + } + } + } + var gutterTrailingPadding: CGFloat = 3 { + didSet { + if gutterTrailingPadding != oldValue { + gutterWidthService.gutterTrailingPadding = gutterTrailingPadding + layoutManager.setNeedsLayout() + textView.setNeedsLayout() + } + } + } + var gutterMinimumCharacterCount: Int = 1 { + didSet { + if gutterMinimumCharacterCount != oldValue { + gutterWidthService.gutterMinimumCharacterCount = gutterMinimumCharacterCount + layoutManager.setNeedsLayout() + textView.setNeedsLayout() + } + } + } + var textContainerInset: MultiPlatformEdgeInsets { + get { + return layoutManager.textContainerInset + } + set { + if newValue != layoutManager.textContainerInset { + caretRectService.textContainerInset = newValue + selectionRectService.textContainerInset = newValue + contentSizeService.textContainerInset = newValue + layoutManager.textContainerInset = newValue + layoutManager.setNeedsLayout() + textView.setNeedsLayout() + } + } + } + var isLineWrappingEnabled: Bool { + get { + return layoutManager.isLineWrappingEnabled + } + set { + if newValue != layoutManager.isLineWrappingEnabled { + contentSizeService.isLineWrappingEnabled = newValue + layoutManager.isLineWrappingEnabled = newValue + invalidateLines() + layoutManager.setNeedsLayout() + layoutManager.layoutIfNeeded() + } + } + } + var lineBreakMode: LineBreakMode = .byWordWrapping { + didSet { + if lineBreakMode != oldValue { + invalidateLines() + contentSizeService.invalidateContentSize() + layoutManager.setNeedsLayout() + layoutManager.layoutIfNeeded() + } + } + } + var gutterWidth: CGFloat { + gutterWidthService.gutterWidth + } + var lineHeightMultiplier: CGFloat = 1 { + didSet { + if lineHeightMultiplier != oldValue { + selectionRectService.lineHeightMultiplier = lineHeightMultiplier + layoutManager.lineHeightMultiplier = lineHeightMultiplier + invalidateLines() + lineManager.estimatedLineHeight = estimatedLineHeight + layoutManager.setNeedsLayout() + textView.setNeedsLayout() + } + } + } + var kern: CGFloat = 0 { + didSet { + if kern != oldValue { + invalidateLines() + pageGuideController.kern = kern + contentSizeService.invalidateContentSize() + layoutManager.setNeedsLayout() + textView.setNeedsLayout() + } + } + } + var showPageGuide = false { + didSet { + if showPageGuide != oldValue { + if showPageGuide { + textView.addSubview(pageGuideController.guideView) + textView.sendSubviewToBack(pageGuideController.guideView) + textView.setNeedsLayout() + } else { + pageGuideController.guideView.removeFromSuperview() + textView.setNeedsLayout() + } + } + } + } + var pageGuideColumn: Int { + get { + return pageGuideController.column + } + set { + if newValue != pageGuideController.column { + pageGuideController.column = newValue + textView.setNeedsLayout() + } + } + } + var verticalOverscrollFactor: CGFloat = 0 { + didSet { + if verticalOverscrollFactor != oldValue { + invalidateContentSizeIfNeeded() + } + } + } + var horizontalOverscrollFactor: CGFloat = 0 { + didSet { + if horizontalOverscrollFactor != oldValue { + invalidateContentSizeIfNeeded() + } + } + } + var lengthOfInitallyLongestLine: Int? { + lineManager.initialLongestLine?.data.totalLength + } + var highlightedRanges: [HighlightedRange] { + get { + return highlightService.highlightedRanges + } + set { + if newValue != highlightService.highlightedRanges { + highlightService.highlightedRanges = newValue + layoutManager.setNeedsLayout() + layoutManager.layoutIfNeeded() + highlightNavigationController.highlightedRanges = newValue + } + } + } + var highlightedRangeLoopingMode: HighlightedRangeLoopingMode { + get { + if highlightNavigationController.loopRanges { + return .enabled + } else { + return .disabled + } + } + set { + switch newValue { + case .enabled: + highlightNavigationController.loopRanges = true + case .disabled: + highlightNavigationController.loopRanges = false + } + } + } + var isAutomaticScrollEnabled = false + var hasPendingFullLayout = false + private(set) var maximumLeadingCharacterPairComponentLength = 0 + + private var estimatedLineHeight: CGFloat { + theme.font.totalLineHeight * lineHeightMultiplier + } + private var preserveUndoStackWhenSettingString = false + private var cancellables: Set = [] + + init(textView: TextView) { + _textView = textView + lineManager = LineManager(stringView: stringView) + highlightService = HighlightService(lineManager: lineManager) + lineControllerFactory = LineControllerFactory( + stringView: stringView, + highlightService: highlightService, + invisibleCharacterConfiguration: invisibleCharacterConfiguration + ) + lineControllerStorage = LineControllerStorage( + stringView: stringView, + lineControllerFactory: lineControllerFactory + ) + gutterWidthService = GutterWidthService(lineManager: lineManager) + contentSizeService = ContentSizeService( + lineManager: lineManager, + lineControllerStorage: lineControllerStorage, + gutterWidthService: gutterWidthService, + invisibleCharacterConfiguration: invisibleCharacterConfiguration + ) + caretRectService = CaretRectService( + stringView: stringView, + lineManager: lineManager, + lineControllerStorage: lineControllerStorage, + gutterWidthService: gutterWidthService + ) + selectionRectService = SelectionRectService( + lineManager: lineManager, + contentSizeService: contentSizeService, + gutterWidthService: gutterWidthService, + caretRectService: caretRectService + ) + layoutManager = LayoutManager( + lineManager: lineManager, + languageMode: languageMode, + stringView: stringView, + lineControllerStorage: lineControllerStorage, + contentSizeService: contentSizeService, + gutterWidthService: gutterWidthService, + caretRectService: caretRectService, + selectionRectService: selectionRectService, + highlightService: highlightService, + invisibleCharacterConfiguration: invisibleCharacterConfiguration + ) + indentController = IndentController( + stringView: stringView, + lineManager: lineManager, + languageMode: languageMode, + indentStrategy: indentStrategy, + indentFont: theme.font + ) + lineMovementController = LineMovementController( + lineManager: lineManager, + stringView: stringView, + lineControllerStorage: lineControllerStorage + ) + layoutManager.delegate = self + layoutManager.containerView = textView + applyThemeToChildren() + indentController.delegate = self + lineControllerStorage.delegate = self + gutterWidthService.gutterLeadingPadding = gutterLeadingPadding + gutterWidthService.gutterTrailingPadding = gutterTrailingPadding + setupContentSizeObserver() + setupGutterWidthObserver() + } + + func setState(_ state: TextViewState, addUndoAction: Bool = false) { + let oldText = stringView.string + let newText = state.stringView.string + stringView = state.stringView + theme = state.theme + languageMode = state.languageMode + lineControllerStorage.removeAllLineControllers() + lineManager = state.lineManager + lineManager.estimatedLineHeight = estimatedLineHeight + layoutManager.languageMode = state.languageMode + layoutManager.lineManager = state.lineManager + contentSizeService.invalidateContentSize() + gutterWidthService.invalidateLineNumberWidth() + if addUndoAction { + if newText != oldText { + let newRange = NSRange(location: 0, length: newText.length) + timedUndoManager.endUndoGrouping() + timedUndoManager.beginUndoGrouping() + addUndoOperation(replacing: newRange, withText: oldText as String) + timedUndoManager.endUndoGrouping() + } + } else { + timedUndoManager.removeAllActions() + } + if let oldSelectedRange = selectedRange { + #if os(iOS) + textView.inputDelegate?.selectionWillChange(textView) + selectedRange = safeSelectionRange(from: oldSelectedRange) + textView.inputDelegate?.selectionDidChange(textView) + #endif + } + if textView.window != nil { + performFullLayout() + } else { + hasPendingFullLayout = true + } + } + + func setLanguageMode(_ languageMode: LanguageMode, completion: ((Bool) -> Void)? = nil) { + let internalLanguageMode = InternalLanguageModeFactory.internalLanguageMode( + from: languageMode, + stringView: stringView, + lineManager: lineManager + ) + self.languageMode = internalLanguageMode + layoutManager.languageMode = internalLanguageMode + internalLanguageMode.parse(stringView.string) { [weak self] finished in + if let self = self, finished { + self.invalidateLines() + self.layoutManager.setNeedsLayout() + self.layoutManager.layoutIfNeeded() + } + completion?(finished) + } + } + + func highlightedRange(for range: NSRange) -> HighlightedRange? { + highlightedRanges.first(where: { $0.range == selectedRange }) + } +} + +private extension TextViewController { + private func applyThemeToChildren() { + gutterWidthService.font = theme.lineNumberFont + lineManager.estimatedLineHeight = estimatedLineHeight + indentController.indentFont = theme.font + pageGuideController.font = theme.font + pageGuideController.guideView.hairlineWidth = theme.pageGuideHairlineWidth + pageGuideController.guideView.hairlineColor = theme.pageGuideHairlineColor + pageGuideController.guideView.backgroundColor = theme.pageGuideBackgroundColor + layoutManager.theme = theme + } + + private func setupContentSizeObserver() { + contentSizeService.$isContentSizeInvalid.filter { $0 }.sink { [weak self] _ in + if self?._textView != nil { + self?.invalidateContentSizeIfNeeded() + } + }.store(in: &cancellables) + } + + private func setupGutterWidthObserver() { + gutterWidthService.didUpdateGutterWidth.sink { [weak self] in + if let self = self, let textView = self._textView { + // Typeset lines again when the line number width changes since changing line number width may increase or reduce the number of line fragments in a line. + textView.setNeedsLayout() + self.invalidateLines() + self.layoutManager.setNeedsLayout() + textView.editorDelegate?.textViewDidChangeGutterWidth(self.textView) + } + }.store(in: &cancellables) + } +} + +// MARK: - TreeSitterLanguageModeDelegate +extension TextViewController: TreeSitterLanguageModeDelegate { + func treeSitterLanguageMode(_ languageMode: TreeSitterInternalLanguageMode, bytesAt byteIndex: ByteCount) -> TreeSitterTextProviderResult? { + guard byteIndex.value >= 0 && byteIndex < stringView.string.byteCount else { + return nil + } + let targetByteCount: ByteCount = 4 * 1_024 + let endByte = min(byteIndex + targetByteCount, stringView.string.byteCount) + let byteRange = ByteRange(from: byteIndex, to: endByte) + if let result = stringView.bytes(in: byteRange) { + return TreeSitterTextProviderResult(bytes: result.bytes, length: UInt32(result.length.value)) + } else { + return nil + } + } +} + +// MARK: - LayoutManagerDelegate +extension TextViewController: LayoutManagerDelegate { + func layoutManager(_ layoutManager: LayoutManager, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) { + let isScrolling = textView.isDragging || textView.isDecelerating + if contentOffsetAdjustment != .zero && isScrolling { + textView.contentOffset = CGPoint( + x: textView.contentOffset.x + contentOffsetAdjustment.x, + y: textView.contentOffset.y + contentOffsetAdjustment.y + ) + } + } +} + +// MARK: - LineControllerStorageDelegate +extension TextViewController: 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 TextViewController: LineControllerDelegate { + func lineSyntaxHighlighter(for lineController: LineController) -> LineSyntaxHighlighter? { + let syntaxHighlighter = languageMode.createLineSyntaxHighlighter() + syntaxHighlighter.kern = kern + return syntaxHighlighter + } + + func lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ lineController: LineController) { + textView.setNeedsLayout() + layoutManager.setNeedsLayout() + } +} + +// MARK: - IndentControllerDelegate +extension TextViewController: IndentControllerDelegate { + func indentController(_ controller: IndentController, shouldInsert text: String, in range: NSRange) { + replaceText(in: range, with: text) + } + + func indentController(_ controller: IndentController, shouldSelect range: NSRange) { + #if os(iOS) + textView.inputDelegate?.selectionWillChange(textView) + selectedRange = range + textView.inputDelegate?.selectionDidChange(textView) + #else + selectedRange = range + #endif + } + + func indentControllerDidUpdateTabWidth(_ controller: IndentController) { + invalidateLines() + } +} diff --git a/Sources/Runestone/TextView/Core/iOS/TextInputView.swift b/Sources/Runestone/TextView/Core/iOS/TextInputView.swift index 130e9ac25..506413ff3 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextInputView.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextInputView.swift @@ -496,14 +496,14 @@ final class TextInputView: UIView, UITextInput { override var canBecomeFirstResponder: Bool { return true } - weak var gutterParentView: UIView? { - get { - return layoutManager.gutterParentView - } - set { - layoutManager.gutterParentView = newValue - } - } +// weak var gutterParentView: UIView? { +// get { +// return layoutManager.gutterParentView +// } +// set { +// layoutManager.gutterParentView = newValue +// } +// } var scrollViewSafeAreaInsets: UIEdgeInsets = .zero { didSet { if scrollViewSafeAreaInsets != oldValue { @@ -646,9 +646,9 @@ final class TextInputView: UIView, UITextInput { gutterWidthService.gutterLeadingPadding = gutterLeadingPadding gutterWidthService.gutterTrailingPadding = gutterTrailingPadding layoutManager.delegate = self - layoutManager.textInputView = self - editMenuController.delegate = self - editMenuController.setupEditMenu(in: self) +// layoutManager.textInputView = self +// editMenuController.delegate = self +// editMenuController.setupEditMenu(in: self) setupContentSizeObserver() setupGutterWidthObserver() } @@ -815,53 +815,53 @@ final class TextInputView: UIView, UITextInput { selectedRange = nil } - func moveCaret(to point: CGPoint) { - if let index = layoutManager.closestIndex(to: point) { - selectedRange = NSRange(location: index, length: 0) - } - } - - func setLanguageMode(_ languageMode: LanguageMode, completion: ((Bool) -> Void)? = nil) { - let internalLanguageMode = InternalLanguageModeFactory.internalLanguageMode( - from: languageMode, - stringView: stringView, - lineManager: lineManager) - self.languageMode = internalLanguageMode - layoutManager.languageMode = internalLanguageMode - internalLanguageMode.parse(string) { [weak self] finished in - if let self = self, finished { - self.invalidateLines() - self.layoutManager.setNeedsLayout() - self.layoutManager.layoutIfNeeded() - } - completion?(finished) - } - } - - func syntaxNode(at location: Int) -> SyntaxNode? { - if let linePosition = lineManager.linePosition(at: location) { - return languageMode.syntaxNode(at: linePosition) - } else { - return nil - } - } - - func isIndentation(at location: Int) -> Bool { - guard let line = lineManager.line(containingCharacterAt: location) else { - return false - } - let localLocation = location - line.location - guard localLocation >= 0 else { - return false - } - let indentLevel = languageMode.currentIndentLevel(of: line, using: indentStrategy) - let indentString = indentStrategy.string(indentLevel: indentLevel) - return localLocation <= indentString.utf16.count - } - - func detectIndentStrategy() -> DetectedIndentStrategy { - return languageMode.detectIndentStrategy() - } +// func moveCaret(to point: CGPoint) { +// if let index = layoutManager.closestIndex(to: point) { +// selectedRange = NSRange(location: index, length: 0) +// } +// } + +// func setLanguageMode(_ languageMode: LanguageMode, completion: ((Bool) -> Void)? = nil) { +// let internalLanguageMode = InternalLanguageModeFactory.internalLanguageMode( +// from: languageMode, +// stringView: stringView, +// lineManager: lineManager) +// self.languageMode = internalLanguageMode +// layoutManager.languageMode = internalLanguageMode +// internalLanguageMode.parse(string) { [weak self] finished in +// if let self = self, finished { +// self.invalidateLines() +// self.layoutManager.setNeedsLayout() +// self.layoutManager.layoutIfNeeded() +// } +// completion?(finished) +// } +// } + +// func syntaxNode(at location: Int) -> SyntaxNode? { +// if let linePosition = lineManager.linePosition(at: location) { +// return languageMode.syntaxNode(at: linePosition) +// } else { +// return nil +// } +// } + +// func isIndentation(at location: Int) -> Bool { +// guard let line = lineManager.line(containingCharacterAt: location) else { +// return false +// } +// let localLocation = location - line.location +// guard localLocation >= 0 else { +// return false +// } +// let indentLevel = languageMode.currentIndentLevel(of: line, using: indentStrategy) +// let indentString = indentStrategy.string(indentLevel: indentLevel) +// return localLocation <= indentString.utf16.count +// } + +// func detectIndentStrategy() -> DetectedIndentStrategy { +// return languageMode.detectIndentStrategy() +// } func textPreview(containing range: NSRange) -> TextPreview? { return layoutManager.textPreview(containing: range) @@ -1354,23 +1354,23 @@ extension TextInputView { } // MARK: - Indent and Outdent -extension TextInputView { - func shiftLeft() { - if let selectedRange = selectedRange { - inputDelegate?.textWillChange(self) - indentController.shiftLeft(in: selectedRange) - inputDelegate?.textDidChange(self) - } - } - - func shiftRight() { - if let selectedRange = selectedRange { - inputDelegate?.textWillChange(self) - indentController.shiftRight(in: selectedRange) - inputDelegate?.textDidChange(self) - } - } -} +//extension TextInputView { +// func shiftLeft() { +// if let selectedRange = selectedRange { +// inputDelegate?.textWillChange(self) +// indentController.shiftLeft(in: selectedRange) +// inputDelegate?.textDidChange(self) +// } +// } +// +// func shiftRight() { +// if let selectedRange = selectedRange { +// inputDelegate?.textWillChange(self) +// indentController.shiftRight(in: selectedRange) +// inputDelegate?.textDidChange(self) +// } +// } +//} // MARK: - Move Lines extension TextInputView { @@ -1564,9 +1564,9 @@ extension TextInputView { return editMenuController.editMenu(for: textRange, suggestedActions: suggestedActions) } - func presentEditMenuForText(in range: NSRange) { - editMenuController.presentEditMenu(from: self, forTextIn: range) - } +// func presentEditMenuForText(in range: NSRange) { +// editMenuController.presentEditMenu(from: self, forTextIn: range) +// } @objc private func replaceTextInSelectedHighlightedRange() { if let selectedRange = selectedRange, let highlightedRange = highlightedRange(for: selectedRange) { diff --git a/Sources/Runestone/TextView/Core/iOS/TextView+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView+UITextInput.swift new file mode 100644 index 000000000..a336f4724 --- /dev/null +++ b/Sources/Runestone/TextView/Core/iOS/TextView+UITextInput.swift @@ -0,0 +1,363 @@ +#if os(iOS) +import UIKit + +extension TextView: UITextInput {} + +public extension TextView { + var beginningOfDocument: UITextPosition { + IndexedPosition(index: 0) + } + var endOfDocument: UITextPosition { + IndexedPosition(index: textViewController.stringView.string.length) + } + var hasText: Bool { + textViewController.stringView.string.length > 0 + } + var tokenizer: UITextInputTokenizer { + customTokenizer + } +} + +// MARK: - Caret +public extension TextView { + func caretRect(for position: UITextPosition) -> CGRect { + guard let indexedPosition = position as? IndexedPosition else { + fatalError("Expected position to be of type \(IndexedPosition.self)") + } + return textViewController.caretRectService.caretRect( + at: indexedPosition.index, + allowMovingCaretToNextLineFragment: true + ) + } +} + +// MARK: - Editing +public extension TextView { + func text(in range: UITextRange) -> String? { + if let indexedRange = range as? IndexedRange { + return textViewController.text(in: indexedRange.range) + } else { + return nil + } + } + + func replace(_ range: UITextRange, withText text: String) { + let preparedText = prepareTextForInsertion(text) + if let indexedRange = range as? IndexedRange, shouldChangeText(in: indexedRange.range.nonNegativeLength, replacementText: preparedText) { + textViewController.replaceText(in: indexedRange.range.nonNegativeLength, with: preparedText) + handleTextSelectionChange() + } + } + + func insertText(_ text: String) { + let preparedText = prepareTextForInsertion(text) + isRestoringPreviouslyDeletedText = hasDeletedTextWithPendingLayoutSubviews + hasDeletedTextWithPendingLayoutSubviews = false + defer { + isRestoringPreviouslyDeletedText = false + } + // If there is no marked range or selected range then we fallback to appending text to the end of our string. + let selectedRange = textViewController.markedRange + ?? textViewController.selectedRange + ?? NSRange(location: textViewController.stringView.string.length, length: 0) + guard shouldChangeText(in: selectedRange, replacementText: preparedText) else { + isRestoringPreviouslyDeletedText = false + return + } + // If we're inserting text then we can't have a marked range. However, UITextInput doesn't always clear the marked range + // before calling -insertText(_:), so we do it manually. This issue can be tested by entering a backtick (`) in an empty + // document, then pressing any arrow key (up, right, down or left) followed by the return key. + // The backtick will remain marked unless we manually clear the marked range. + textViewController.markedRange = nil + if LineEnding(symbol: text) != nil { + textViewController.indentController.insertLineBreak(in: selectedRange, using: lineEndings) + layoutIfNeeded() + handleTextSelectionChange() + } else { + textViewController.replaceText(in: selectedRange, with: preparedText) + layoutIfNeeded() + handleTextSelectionChange() + } + } + + func deleteBackward() { + guard let selectedRange = textViewController.markedRange ?? textViewController.selectedRange, selectedRange.length > 0 else { + return + } + let deleteRange = textViewController.rangeForDeletingText(in: selectedRange) + // If we're deleting everything in the marked range then we clear the marked range. UITextInput doesn't do that for us. + // Can be tested by entering a backtick (`) in an empty document and deleting it. + if deleteRange == textViewController.markedRange { + textViewController.markedRange = nil + } + guard shouldChangeText(in: deleteRange, replacementText: "") else { + return + } + // Set a flag indicating that we have deleted text. This is reset in -layoutSubviews() but if this has not been reset before insertText() is called, then UIKit deleted characters prior to inserting combined characters. This happens when UIKit turns Korean characters into a single character. E.g. when typing ㅇ followed by ㅓ UIKit will perform the following operations: + // 1. Delete ㅇ. + // 2. Delete the character before ㅇ. I'm unsure why this is needed. + // 3. Insert the character that was previously before ㅇ. + // 4. Insert the ㅇ and ㅓ but combined into the single character delete ㅇ and then insert 어. + // We can detect this case in insertText() by checking if this variable is true. + hasDeletedTextWithPendingLayoutSubviews = true + // Disable notifying delegate in layout subviews to prevent sending the selected range with length > 0 when deleting text. This aligns with the behavior of UITextView and was introduced to resolve issue #158: https://github.com/simonbs/Runestone/issues/158 + notifyDelegateAboutSelectionChangeInLayoutSubviews = false + // Disable notifying input 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. + 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 + if deleteRange.length == 1 { + selectedRangeAfterUndo = NSRange(location: selectedRange.upperBound, length: 0) + } else { + selectedRangeAfterUndo = selectedRange + } + let isDeletingMultipleCharacters = selectedRange.length > 1 + if isDeletingMultipleCharacters { + undoManager?.endUndoGrouping() + undoManager?.beginUndoGrouping() + } + textViewController.replaceText(in: deleteRange, with: "", selectedRangeAfterUndo: selectedRangeAfterUndo) + // Sending selection changed without calling the input delegate directly. This ensures that both inputting Korean letters and deleting entire words with Option+Backspace works properly. + sendSelectionChangedToTextSelectionView() + if isDeletingMultipleCharacters { + undoManager?.endUndoGrouping() + } + handleTextSelectionChange() + } +} + +// MARK: - Selection +public extension TextView { + var selectedTextRange: UITextRange? { + get { + if let range = textViewController.selectedRange { + return IndexedRange(range) + } else { + return nil + } + } + set { + // We should not use this setter. It's intended for UIKit to use. It'll invoke the setter in various scenarios, for example when navigating the text using the keyboard. + // On the iOS 16 beta, UIKit may pass an NSRange with a negatives length (e.g. {4, -2}) when double tapping to select text. This will cause a crash when UIKit later attempts to use the selected range with NSString's -substringWithRange:. This can be tested with a string containing the following three lines: + // A + // + // A + // Placing the character on the second line, which is empty, and double tapping several times on the empty line to select text will cause the editor to crash. To work around this we take the non-negative value of the selected range. Last tested on August 30th, 2022. + let newRange = (newValue as? IndexedRange)?.range.nonNegativeLength + if newRange != textViewController.selectedRange { + notifyDelegateAboutSelectionChangeInLayoutSubviews = 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 + } + notifyInputDelegateAboutSelectionChangeInLayoutSubviews = !shouldNotifyInputDelegate + if shouldNotifyInputDelegate { + inputDelegate?.selectionWillChange(self) + } + textViewController.selectedRange = newRange + if shouldNotifyInputDelegate { + inputDelegate?.selectionDidChange(self) + } + } + } + } + + func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { + if let indexedRange = range as? IndexedRange { + return textViewController.selectionRectService.selectionRects(in: indexedRange.range.nonNegativeLength) + } else { + return [] + } + } +} + +// MARK: - Marking +public extension TextView { + var markedTextStyle: [NSAttributedString.Key : Any]? { + get { return nil } + set {} + } + + var markedTextRange: UITextRange? { + get { + if let markedRange = textViewController.markedRange { + return IndexedRange(markedRange) + } else { + return nil + } + } + set { + textViewController.markedRange = (newValue as? IndexedRange)?.range.nonNegativeLength + } + } + + func setMarkedText(_ markedText: String?, selectedRange: NSRange) { + guard let range = textViewController.markedRange ?? textViewController.selectedRange else { + return + } + let markedText = markedText ?? "" + guard shouldChangeText(in: range, replacementText: markedText) else { + return + } + textViewController.markedRange = markedText.isEmpty ? nil : NSRange(location: range.location, length: markedText.utf16.count) + textViewController.replaceText(in: range, with: markedText) + // The selected range passed to setMarkedText(_:selectedRange:) is local to the marked range. + let preferredSelectedRange = NSRange(location: range.location + selectedRange.location, length: selectedRange.length) + inputDelegate?.selectionWillChange(self) + textViewController.selectedRange = textViewController.safeSelectionRange(from: preferredSelectedRange) + inputDelegate?.selectionDidChange(self) + removeAndAddEditableTextInteraction() + } + + func unmarkText() { + inputDelegate?.selectionWillChange(self) + textViewController.markedRange = nil + inputDelegate?.selectionDidChange(self) + removeAndAddEditableTextInteraction() + } +} + +// MARK: - Ranges and Positions +public extension TextView { + func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { + guard let fromIndexedPosition = fromPosition as? IndexedPosition, let toIndexedPosition = toPosition as? IndexedPosition else { + return nil + } + let range = NSRange(location: fromIndexedPosition.index, length: toIndexedPosition.index - fromIndexedPosition.index) + return IndexedRange(range) + } + + func position(from position: UITextPosition, offset: Int) -> UITextPosition? { + guard let indexedPosition = position as? IndexedPosition else { + return nil + } + let newPosition = indexedPosition.index + offset + guard newPosition >= 0 && newPosition <= textViewController.stringView.string.length else { + return nil + } + return IndexedPosition(index: newPosition) + } + + func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? { + guard let indexedPosition = position as? IndexedPosition else { + return nil + } + didCallPositionFromPositionInDirectionWithOffset = true + guard let newLocation = textViewController.lineMovementController.location( + from: indexedPosition.index, + in: direction, + offset: offset + ) else { + return nil + } + return IndexedPosition(index: newLocation) + } + + func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { + guard let indexedPosition = position as? IndexedPosition, let otherIndexedPosition = other as? IndexedPosition else { + #if targetEnvironment(macCatalyst) + // Mac Catalyst may pass to `position`. I'm not sure what the right way to deal with that is but returning .orderedSame seems to work. + return .orderedSame + #else + fatalError("Positions must be of type \(IndexedPosition.self)") + #endif + } + if indexedPosition.index < otherIndexedPosition.index { + return .orderedAscending + } else if indexedPosition.index > otherIndexedPosition.index { + return .orderedDescending + } else { + return .orderedSame + } + } + + func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { + if let fromPosition = from as? IndexedPosition, let toPosition = toPosition as? IndexedPosition { + return toPosition.index - fromPosition.index + } else { + return 0 + } + } + + func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { + // This implementation seems to match the behavior of UITextView. + guard let indexedRange = range as? IndexedRange else { + return nil + } + switch direction { + case .left, .up: + return IndexedPosition(index: indexedRange.range.lowerBound) + case .right, .down: + return IndexedPosition(index: indexedRange.range.upperBound) + @unknown default: + return nil + } + } + + func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { + // This implementation seems to match the behavior of UITextView. + guard let indexedPosition = position as? IndexedPosition else { + return nil + } + switch direction { + case .left, .up: + let leftIndex = max(indexedPosition.index - 1, 0) + return IndexedRange(location: leftIndex, length: indexedPosition.index - leftIndex) + case .right, .down: + let rightIndex = min(indexedPosition.index + 1, textViewController.stringView.string.length) + return IndexedRange(location: indexedPosition.index, length: rightIndex - indexedPosition.index) + @unknown default: + return nil + } + } + + func firstRect(for range: UITextRange) -> CGRect { + guard let indexedRange = range as? IndexedRange else { + fatalError("Expected range to be of type \(IndexedRange.self)") + } + return textViewController.layoutManager.firstRect(for: indexedRange.range) + } + + func closestPosition(to point: CGPoint) -> UITextPosition? { + if let index = textViewController.layoutManager.closestIndex(to: point) { + return IndexedPosition(index: index) + } else { + return nil + } + } + + func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { + guard let indexedRange = range as? IndexedRange else { + return nil + } + guard let index = textViewController.layoutManager.closestIndex(to: point) else { + return nil + } + let minimumIndex = indexedRange.range.lowerBound + let maximumIndex = indexedRange.range.upperBound + let cappedIndex = min(max(index, minimumIndex), maximumIndex) + return IndexedPosition(index: cappedIndex) + } + + func characterRange(at point: CGPoint) -> UITextRange? { + guard let index = textViewController.layoutManager.closestIndex(to: point) else { + return nil + } + let cappedIndex = max(index - 1, 0) + let range = textViewController.stringView.string.customRangeOfComposedCharacterSequence(at: cappedIndex) + return IndexedRange(range) + } +} + +// MARK: - Writing Direction +public extension TextView { + func baseWritingDirection(for position: UITextPosition, in direction: UITextStorageDirection) -> NSWritingDirection { + .natural + } + + func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) {} +} +#endif diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift deleted file mode 100644 index d8839345c..000000000 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ /dev/null @@ -1,1503 +0,0 @@ -#if os(iOS) -// swiftlint:disable file_length type_body_length -import CoreText -import UIKit - -/// A type similiar to UITextView with features commonly found in code editors. -/// -/// `TextView` is a performant implementation of a text view with features such as showing line numbers, searching for text and replacing results, syntax highlighting, showing invisible characters and more. -/// -/// The type does not subclass `UITextView` but its interface is kept close to `UITextView`. -/// -/// When initially configuring the `TextView` with a theme, a language and the text to be shown, it is recommended to use the ``setState(_:addUndoAction:)`` function. -/// The function takes an instance of ``TextViewState`` as input which can be created on a background queue to avoid blocking the main queue while doing the initial parse of a text. -open class TextView: UIScrollView { - /// Delegate to receive callbacks for events triggered by the editor. - public weak var editorDelegate: TextViewDelegate? - /// Whether the text view is in a state where the contents can be edited. - public private(set) var isEditing = false { - didSet { - if isEditing != oldValue { - textInputView.isEditing = isEditing - } - } - } - /// The text that the text view displays. - public var text: String { - get { - return textInputView.string as String - } - set { - textInputView.string = newValue as NSString - contentSize = preferredContentSize - } - } - /// A Boolean value that indicates whether the text view is editable. - public var isEditable = true { - didSet { - if isEditable != oldValue && !isEditable && isEditing { - resignFirstResponder() - textInputViewDidEndEditing(textInputView) - } - } - } - /// A Boolean value that indicates whether the text view is selectable. - public var isSelectable = true { - didSet { - if isSelectable != oldValue { - textInputView.isUserInteractionEnabled = isSelectable - if !isSelectable && isEditing { - resignFirstResponder() - textInputView.clearSelection() - textInputViewDidEndEditing(textInputView) - } - } - } - } - /// Colors and fonts to be used by the editor. - public var theme: Theme { - get { - return textInputView.theme - } - set { - textInputView.theme = newValue - } - } - /// The autocorrection style for the text view. - public var autocorrectionType: UITextAutocorrectionType { - get { - return textInputView.autocorrectionType - } - set { - textInputView.autocorrectionType = newValue - } - } - /// The autocapitalization style for the text view. - public var autocapitalizationType: UITextAutocapitalizationType { - get { - return textInputView.autocapitalizationType - } - set { - textInputView.autocapitalizationType = newValue - } - } - /// The spell-checking style for the text view. - public var smartQuotesType: UITextSmartQuotesType { - get { - return textInputView.smartQuotesType - } - set { - textInputView.smartQuotesType = newValue - } - } - /// The configuration state for smart dashes. - public var smartDashesType: UITextSmartDashesType { - get { - return textInputView.smartDashesType - } - set { - textInputView.smartDashesType = newValue - } - } - /// The configuration state for the smart insertion and deletion of space characters. - public var smartInsertDeleteType: UITextSmartInsertDeleteType { - get { - return textInputView.smartInsertDeleteType - } - set { - textInputView.smartInsertDeleteType = newValue - } - } - /// The spell-checking style for the text object. - public var spellCheckingType: UITextSpellCheckingType { - get { - return textInputView.spellCheckingType - } - set { - textInputView.spellCheckingType = newValue - } - } - /// The keyboard type for the text view. - public var keyboardType: UIKeyboardType { - get { - return textInputView.keyboardType - } - set { - textInputView.keyboardType = newValue - } - } - /// The appearance style of the keyboard for the text view. - public var keyboardAppearance: UIKeyboardAppearance { - get { - return textInputView.keyboardAppearance - } - set { - textInputView.keyboardAppearance = newValue - } - } - /// The display of the return key. - public var returnKeyType: UIReturnKeyType { - get { - return textInputView.returnKeyType - } - set { - textInputView.returnKeyType = newValue - } - } - /// Returns the undo manager used by the text view. - override public var undoManager: UndoManager? { - return textInputView.undoManager - } - /// The color of the insertion point. This can be used to control the color of the caret. - public var insertionPointColor: UIColor { - get { - return textInputView.insertionPointColor - } - set { - textInputView.insertionPointColor = newValue - } - } - /// The color of the selection bar. It is most common to set this to the same color as the color used for the insertion point. - public var selectionBarColor: UIColor { - get { - return textInputView.selectionBarColor - } - set { - textInputView.selectionBarColor = newValue - } - } - /// The color of the selection highlight. It is most common to set this to the same color as the color used for the insertion point. - public var selectionHighlightColor: UIColor { - get { - return textInputView.selectionHighlightColor - } - set { - textInputView.selectionHighlightColor = newValue - } - } - /// The current selection range of the text view. - public var selectedRange: NSRange { - get { - if let selectedRange = textInputView.selectedRange { - return selectedRange - } else { - // UITextView returns the end of the document for the selectedRange by default. - return NSRange(location: textInputView.string.length, length: 0) - } - } - set { - textInputView.selectedRange = newValue - } - } - /// The current selection range of the text view as a UITextRange. - public var selectedTextRange: UITextRange? { - get { - return textInputView.selectedTextRange - } - set { - textInputView.selectedTextRange = newValue - } - } - /// The custom input accessory view to display when the receiver becomes the first responder. - override public var inputAccessoryView: UIView? { - get { - if isInputAccessoryViewEnabled { - return _inputAccessoryView - } else { - return nil - } - } - set { - _inputAccessoryView = newValue - } - } - /// The input assistant to use when configuring the keyboard's shortcuts bar. - override public var inputAssistantItem: UITextInputAssistantItem { - return textInputView.inputAssistantItem - } - /// Returns a Boolean value indicating whether this object can become the first responder. - override public var canBecomeFirstResponder: Bool { - return !textInputView.isFirstResponder && isEditable - } - /// The text view's background color. - override public var backgroundColor: UIColor? { - get { - return textInputView.backgroundColor - } - set { - super.backgroundColor = newValue - textInputView.backgroundColor = newValue - } - } - /// The point at which the origin of the content view is offset from the origin of the scroll view. - override public var contentOffset: CGPoint { - didSet { - if contentOffset != oldValue { - textInputView.viewport = CGRect(origin: contentOffset, size: frame.size) - } - } - } - /// Character pairs are used by the editor to automatically insert a trailing character when the user types the leading character. - /// - /// Common usages of this includes the \" character to surround strings and { } to surround a scope. - public var characterPairs: [CharacterPair] { - get { - return textInputView.characterPairs - } - set { - textInputView.characterPairs = newValue - } - } - /// Determines what should happen to the trailing component of a character pair when deleting the leading component. Defaults to `disabled` meaning that nothing will happen. - public var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode { - get { - return textInputView.characterPairTrailingComponentDeletionMode - } - set { - textInputView.characterPairTrailingComponentDeletionMode = newValue - } - } - /// Enable to show line numbers in the gutter. - public var showLineNumbers: Bool { - get { - return textInputView.showLineNumbers - } - set { - textInputView.showLineNumbers = newValue - } - } - /// Enable to show highlight the selected lines. The selection is only shown in the gutter when multiple lines are selected. - public var lineSelectionDisplayType: LineSelectionDisplayType { - get { - return textInputView.lineSelectionDisplayType - } - set { - textInputView.lineSelectionDisplayType = newValue - } - } - /// The text view renders invisible tabs when enabled. The `tabsSymbol` is used to render tabs. - public var showTabs: Bool { - get { - return textInputView.showTabs - } - set { - textInputView.showTabs = newValue - } - } - /// The text view renders invisible spaces when enabled. - /// - /// he `spaceSymbol` is used to render spaces. - public var showSpaces: Bool { - get { - return textInputView.showSpaces - } - set { - textInputView.showSpaces = newValue - } - } - /// The text view renders invisible spaces when enabled. - /// - /// The `nonBreakingSpaceSymbol` is used to render spaces. - public var showNonBreakingSpaces: Bool { - get { - return textInputView.showNonBreakingSpaces - } - set { - textInputView.showNonBreakingSpaces = newValue - } - } - /// The text view renders invisible line breaks when enabled. - /// - /// The `lineBreakSymbol` is used to render line breaks. - public var showLineBreaks: Bool { - get { - return textInputView.showLineBreaks - } - set { - textInputView.showLineBreaks = newValue - } - } - /// The text view renders invisible soft line breaks when enabled. - /// - /// The `softLineBreakSymbol` is used to render line breaks. These line breaks are typically represented by the U+2028 unicode character. Runestone does not provide any key commands for inserting these but supports rendering them. - public var showSoftLineBreaks: Bool { - get { - return textInputView.showSoftLineBreaks - } - set { - textInputView.showSoftLineBreaks = newValue - } - } - /// Symbol used to display tabs. - /// - /// The value is only used when invisible tab characters is enabled. The default is ▸. - /// - /// Common characters for this symbol include ▸, ⇥, ➜, ➞, and ❯. - public var tabSymbol: String { - get { - return textInputView.tabSymbol - } - set { - textInputView.tabSymbol = newValue - } - } - /// Symbol used to display spaces. - /// - /// The value is only used when showing invisible space characters is enabled. The default is ·. - /// - /// Common characters for this symbol include ·, •, and _. - public var spaceSymbol: String { - get { - return textInputView.spaceSymbol - } - set { - textInputView.spaceSymbol = newValue - } - } - /// Symbol used to display non-breaking spaces. - /// - /// The value is only used when showing invisible space characters is enabled. The default is ·. - /// - /// Common characters for this symbol include ·, •, and _. - public var nonBreakingSpaceSymbol: String { - get { - return textInputView.nonBreakingSpaceSymbol - } - set { - textInputView.nonBreakingSpaceSymbol = newValue - } - } - /// Symbol used to display line break. - /// - /// The value is only used when showing invisible line break characters is enabled. The default is ¬. - /// - /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. - public var lineBreakSymbol: String { - get { - return textInputView.lineBreakSymbol - } - set { - textInputView.lineBreakSymbol = newValue - } - } - /// Symbol used to display soft line breaks. - /// - /// The value is only used when showing invisible soft line break characters is enabled. The default is ¬. - /// - /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. - public var softLineBreakSymbol: String { - get { - return textInputView.softLineBreakSymbol - } - set { - textInputView.softLineBreakSymbol = newValue - } - } - /// The strategy used when indenting text. - public var indentStrategy: IndentStrategy { - get { - return textInputView.indentStrategy - } - set { - textInputView.indentStrategy = newValue - } - } - /// The amount of padding before the line numbers inside the gutter. - public var gutterLeadingPadding: CGFloat { - get { - return textInputView.gutterLeadingPadding - } - set { - textInputView.gutterLeadingPadding = newValue - } - } - /// The amount of padding after the line numbers inside the gutter. - public var gutterTrailingPadding: CGFloat { - get { - return textInputView.gutterTrailingPadding - } - set { - textInputView.gutterTrailingPadding = newValue - } - } - /// The minimum amount of characters to use for width calculation inside the gutter. - public var gutterMinimumCharacterCount: Int { - get { - return textInputView.gutterMinimumCharacterCount - } - set { - textInputView.gutterMinimumCharacterCount = newValue - } - } - /// The amount of spacing surrounding the lines. - public var textContainerInset: UIEdgeInsets { - get { - return textInputView.textContainerInset - } - set { - textInputView.textContainerInset = newValue - } - } - /// When line wrapping is disabled, users can scroll the text view horizontally to see the entire line. - /// - /// Line wrapping is enabled by default. - public var isLineWrappingEnabled: Bool { - get { - return textInputView.isLineWrappingEnabled - } - set { - textInputView.isLineWrappingEnabled = newValue - } - } - /// Line break mode for text view. The default value is .byWordWrapping meaning that wrapping occurs on word boundaries. - public var lineBreakMode: LineBreakMode { - get { - return textInputView.lineBreakMode - } - set { - textInputView.lineBreakMode = newValue - } - } - /// Width of the gutter. - public var gutterWidth: CGFloat { - return textInputView.gutterWidth - } - /// The line-height is multiplied with the value. - public var lineHeightMultiplier: CGFloat { - get { - return textInputView.lineHeightMultiplier - } - set { - textInputView.lineHeightMultiplier = newValue - } - } - /// The number of points by which to adjust kern. The default value is 0 meaning that kerning is disabled. - public var kern: CGFloat { - get { - return textInputView.kern - } - set { - textInputView.kern = newValue - } - } - /// The text view shows a page guide when enabled. Use `pageGuideColumn` to specify the location of the page guide. - public var showPageGuide: Bool { - get { - return textInputView.showPageGuide - } - set { - textInputView.showPageGuide = newValue - } - } - /// Specifies the location of the page guide. Use `showPageGuide` to specify if the page guide should be shown. - public var pageGuideColumn: Int { - get { - return textInputView.pageGuideColumn - } - set { - textInputView.pageGuideColumn = newValue - } - } - /// Automatically scrolls the text view to show the caret when typing or moving the caret. - public var isAutomaticScrollEnabled = true - /// Amount of overscroll to add in the vertical direction. - /// - /// The overscroll is a factor of the scrollable area height and will not take into account any insets. 0 means no overscroll and 1 means an amount equal to the height of the text view. Detaults to 0. - public var verticalOverscrollFactor: CGFloat = 0 { - didSet { - if horizontalOverscrollFactor != oldValue { - hasPendingContentSizeUpdate = true - handleContentSizeUpdateIfNeeded() - } - } - } - /// Amount of overscroll to add in the horizontal direction. - /// - /// The overscroll is a factor of the scrollable area height and will not take into account any insets or the width of the gutter. 0 means no overscroll and 1 means an amount equal to the width of the text view. Detaults to 0. - public var horizontalOverscrollFactor: CGFloat = 0 { - didSet { - if horizontalOverscrollFactor != oldValue { - hasPendingContentSizeUpdate = true - handleContentSizeUpdateIfNeeded() - } - } - } - /// 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? { - return 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 { - return textInputView.highlightedRanges - } - set { - textInputView.highlightedRanges = newValue - highlightNavigationController.highlightedRanges = newValue - } - } - /// Wheter the text view should loop when navigating through highlighted ranges using `selectPreviousHighlightedRange` or `selectNextHighlightedRange` on the text view. - public var highlightedRangeLoopingMode: HighlightedRangeLoopingMode { - get { - if highlightNavigationController.loopRanges { - return .enabled - } else { - return .disabled - } - } - set { - switch newValue { - case .enabled: - highlightNavigationController.loopRanges = true - case .disabled: - highlightNavigationController.loopRanges = false - } - } - } - /// Line endings to use when inserting a line break. - /// - /// The value only affects new line breaks inserted in the text view and changing this value does not change the line endings of the text in the text view. Defaults to Unix (LF). - /// - /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)`` and ``TextView/replaceText(in:)``. - public var lineEndings: LineEnding { - get { - return textInputView.lineEndings - } - set { - textInputView.lineEndings = newValue - } - } - /// 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. - @available(iOS 16, *) - public var isFindInteractionEnabled: Bool { - get { - return textSearchingHelper.isFindInteractionEnabled - } - set { - textSearchingHelper.isFindInteractionEnabled = newValue - } - } - /// The text view’s built-in find interaction. - /// - /// Set to true to enable the text view's built-in find interaction. This method returns nil when the interaction isn't enabled. - /// - /// Call [`presentFindNavigator(showingReplace:)`](https://developer.apple.com/documentation/uikit/uifindinteraction/3975832-presentfindnavigator) on the UIFindInteraction object to invoke the find interaction and display the find panel. - @available(iOS 16, *) - public var findInteraction: UIFindInteraction? { - return 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? { - return _editMenuInteraction as? UIEditMenuInteraction - } - private var _editMenuInteraction: Any? -#endif - private let tapGestureRecognizer = QuickTapGestureRecognizer() - private var _inputAccessoryView: UIView? - private let _inputAssistantItem = UITextInputAssistantItem() - private var isPerformingNonEditableTextInteraction = false - private var delegateAllowsEditingToBegin: Bool { - guard isEditable else { - return false - } - if let editorDelegate = editorDelegate { - return editorDelegate.textViewShouldBeginEditing(self) - } else { - return true - } - } - private var shouldEndEditing: Bool { - if let editorDelegate = editorDelegate { - return editorDelegate.textViewShouldEndEditing(self) - } else { - return true - } - } - private var hasPendingContentSizeUpdate = false - private var isInputAccessoryViewEnabled = false - private let keyboardObserver = KeyboardObserver() - private let highlightNavigationController = HighlightNavigationController() - private var textSearchingHelper = UITextSearchingHelper() - // Store a reference to instances of the private type UITextRangeAdjustmentGestureRecognizer in order to track adjustments - // to the selected text range and scroll the text view when the handles approach the bottom. - // The approach is based on the one described in Steve Shephard's blog post "Adventures with UITextInteraction". - // https://steveshepard.com/blog/adventures-with-uitextinteraction/ - private var textRangeAdjustmentGestureRecognizers: Set = [] - private var previousSelectedRangeDuringGestureHandling: NSRange? - private var preferredContentSize: CGSize { - let horizontalOverscrollLength = max(frame.width * horizontalOverscrollFactor, 0) - let verticalOverscrollLength = max(frame.height * verticalOverscrollFactor, 0) - let baseContentSize = textInputView.contentSize - let width = isLineWrappingEnabled ? baseContentSize.width : baseContentSize.width + horizontalOverscrollLength - let height = baseContentSize.height + verticalOverscrollLength - return CGSize(width: width, height: height) - } - - /// Create a new text view. - /// - Parameter frame: The frame rectangle of the text view. - override public init(frame: CGRect) { - textInputView = TextInputView(theme: DefaultTheme()) - super.init(frame: frame) - backgroundColor = .white - textInputView.delegate = self - textInputView.gutterParentView = self - editableTextInteraction.textInput = textInputView - nonEditableTextInteraction.textInput = textInputView - editableTextInteraction.delegate = self - nonEditableTextInteraction.delegate = self - addSubview(textInputView) - tapGestureRecognizer.delegate = self - tapGestureRecognizer.addTarget(self, action: #selector(handleTap(_:))) - addGestureRecognizer(tapGestureRecognizer) - installNonEditableInteraction() - keyboardObserver.delegate = self - highlightNavigationController.delegate = self - textSearchingHelper.textView = self - } - - /// The initializer has not been implemented. - /// - Parameter coder: Not used. - public required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - /// Lays out subviews. - override open func layoutSubviews() { - super.layoutSubviews() - handleContentSizeUpdateIfNeeded() - textInputView.scrollViewWidth = frame.width - textInputView.frame = CGRect(x: 0, y: 0, width: max(contentSize.width, frame.width), height: max(contentSize.height, frame.height)) - textInputView.viewport = CGRect(origin: contentOffset, size: frame.size) - bringSubviewToFront(textInputView.gutterContainerView) - } - - /// Called when the safe area of the view changes. - override open func safeAreaInsetsDidChange() { - super.safeAreaInsetsDidChange() - textInputView.scrollViewSafeAreaInsets = safeAreaInsets - contentSize = preferredContentSize - layoutIfNeeded() - } - - /// Asks UIKit to make this object the first responder in its window. - @discardableResult - override open func becomeFirstResponder() -> Bool { - if !isEditing && delegateAllowsEditingToBegin { - _ = textInputView.resignFirstResponder() - _ = textInputView.becomeFirstResponder() - return true - } else { - return false - } - } - - /// Notifies this object that it has been asked to relinquish its status as first responder in its window. - @discardableResult - override open func resignFirstResponder() -> Bool { - if isEditing && shouldEndEditing { - return textInputView.resignFirstResponder() - } else { - return false - } - } - - /// Updates the custom input and accessory views when the object is the first responder. - override open func reloadInputViews() { - textInputView.reloadInputViews() - } - - /// Sets the current _state_ of the editor. The state contains the text to be displayed by the editor and - /// various additional information about the text that the editor needs to show the text. - /// - /// It is safe to create an instance of TextViewState in the background, and as such it can be - /// created before presenting the editor to the user, e.g. when opening the document from an instance of - /// UIDocumentBrowserViewController. - /// - /// This is the preferred way to initially set the text, language and theme on the TextView. - /// - Parameter state: The new state to be used by the editor. - /// - Parameter addUndoAction: Whether the state change can be undone. Defaults to false. - public func setState(_ state: TextViewState, addUndoAction: Bool = false) { - textInputView.setState(state, addUndoAction: addUndoAction) - contentSize = preferredContentSize - } - - /// Returns the row and column at the specified location in the text. - /// Common usages of this includes showing the line and column that the caret is currently located at. - /// - Parameter location: The location is relative to the first index in the string. - /// - Returns: The text location if the input location could be found in the string, otherwise nil. - public func textLocation(at location: Int) -> TextLocation? { - if let linePosition = textInputView.linePosition(at: location) { - return TextLocation(linePosition) - } else { - return nil - } - } - - /// Returns the character location at the specified row and column. - /// - Parameter textLocation: The row and column in the text. - /// - Returns: The location if the input row and column could be found in the text, otherwise nil. - public func location(at textLocation: TextLocation) -> Int? { - let lineIndex = textLocation.lineNumber - guard lineIndex >= 0 && lineIndex < textInputView.lineManager.lineCount else { - return nil - } - let line = textInputView.lineManager.line(atRow: lineIndex) - guard textLocation.column >= 0 && textLocation.column <= line.data.totalLength else { - return nil - } - return line.location + textLocation.column - } - - /// Sets the language mode on a background thread. - /// - /// - Parameters: - /// - languageMode: The new language mode to be used by the editor. - /// - completion: Called when the content have been parsed or when parsing fails. - public func setLanguageMode(_ languageMode: LanguageMode, completion: ((Bool) -> Void)? = nil) { - textInputView.setLanguageMode(languageMode, completion: completion) - } - - /// Inserts text at the location of the caret or, if no selection or caret is present, at the end of the text. - /// - Parameter text: A string to insert. - open func insertText(_ text: String) { - textInputView.inputDelegate?.selectionWillChange(textInputView) - textInputView.insertText(text) - textInputView.inputDelegate?.selectionDidChange(textInputView) - } - - /// Replaces the text that is in the specified range. - /// - Parameters: - /// - range: A range of text in the document. - /// - text: A string to replace the text in range. - open func replace(_ range: UITextRange, withText text: String) { - textInputView.inputDelegate?.selectionWillChange(textInputView) - textInputView.replace(range, withText: text) - textInputView.inputDelegate?.selectionDidChange(textInputView) - } - - /// Replaces the text that is in the specified range. - /// - Parameters: - /// - range: A range of text in the document. - /// - text: A string to replace the text in range. - public func replace(_ range: NSRange, withText text: String) { - textInputView.inputDelegate?.selectionWillChange(textInputView) - let indexedRange = IndexedRange(range) - textInputView.replace(indexedRange, withText: text) - textInputView.inputDelegate?.selectionDidChange(textInputView) - } - - /// Replaces the text in the specified matches. - /// - Parameters: - /// - batchReplaceSet: Set of ranges to replace with a text. - public func replaceText(in batchReplaceSet: BatchReplaceSet) { - textInputView.replaceText(in: batchReplaceSet) - } - - /// Deletes a character from the displayed text. - public func deleteBackward() { - textInputView.deleteBackward() - } - - /// Returns the text in the specified range. - /// - Parameter range: A range of text in the document. - /// - Returns: The substring that falls within the specified range. - public func text(in range: NSRange) -> String? { - return textInputView.text(in: range) - } - - /// Returns the syntax node at the specified location in the document. - /// - /// This can be used with character pairs to determine if a pair should be inserted or not. - /// For example, a character pair consisting of two quotes (") to surround a string, should probably not be - /// inserted when the quote is typed while the caret is already inside a string. - /// - /// This requires a language to be set on the editor. - /// - Parameter location: A location in the document. - /// - Returns: The syntax node at the location. - public func syntaxNode(at location: Int) -> SyntaxNode? { - return textInputView.syntaxNode(at: location) - } - - /// Checks if the specified locations is within the indentation of the line. - /// - /// - Parameter location: A location in the document. - /// - Returns: True if the location is within the indentation of the line, otherwise false. - public func isIndentation(at location: Int) -> Bool { - return textInputView.isIndentation(at: location) - } - - /// Decreases the indentation level of the selected lines. - public func shiftLeft() { - textInputView.shiftLeft() - } - - /// Increases the indentation level of the selected lines. - public func shiftRight() { - textInputView.shiftRight() - } - - /// Moves the selected lines up by one line. - /// - /// Calling this function has no effect when the selected lines include the first line in the text view. - public func moveSelectedLinesUp() { - textInputView.moveSelectedLinesUp() - } - - /// Moves the selected lines down by one line. - /// - /// Calling this function has no effect when the selected lines include the last line in the text view. - public func moveSelectedLinesDown() { - textInputView.moveSelectedLinesDown() - } - - /// Attempts to detect the indent strategy used in the document. This may return an unknown strategy even - /// when the document contains indentation. - public func detectIndentStrategy() -> DetectedIndentStrategy { - return textInputView.detectIndentStrategy() - } - - /// Go to the beginning of the line at the specified index. - /// - /// - Parameter lineIndex: Index of line to navigate to. - /// - Parameter selection: The placement of the caret on the line. - /// - Returns: True if the text view could navigate to the specified line index, otherwise false. - @discardableResult - public func goToLine(_ lineIndex: Int, select selection: GoToLineSelection = .beginning) -> Bool { - guard lineIndex >= 0 && lineIndex < textInputView.lineManager.lineCount else { - return false - } - // I'm not exactly sure why this is necessary but if the text view is the first responder as we jump - // to the line and we don't resign the first responder first, the caret will disappear after we have - // jumped to the specified line. - resignFirstResponder() - becomeFirstResponder() - let line = textInputView.lineManager.line(atRow: lineIndex) - textInputView.layoutLines(toLocation: line.location) - scrollLocationToVisible(line.location) - layoutIfNeeded() - switch selection { - case .beginning: - textInputView.selectedRange = NSRange(location: line.location, length: 0) - case .end: - textInputView.selectedRange = NSRange(location: line.data.length, length: line.data.length) - case .line: - textInputView.selectedRange = NSRange(location: line.location, length: line.data.length) - } - return true - } - - /// Search for the specified query. - /// - /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:)``. - /// - /// ```swift - /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) - /// let results = textView.search(for: query) - /// ``` - /// - /// - Parameter query: Query to find matches for. - /// - Returns: Results matching the query. - public func search(for query: SearchQuery) -> [SearchResult] { - let searchController = SearchController(stringView: textInputView.stringView) - searchController.delegate = self - return searchController.search(for: query) - } - - /// Search for the specified query and return results that take a replacement string into account. - /// - /// When searching for a regular expression this function will perform pattern matching and take the matched groups into account in the returned results. - /// - /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:replacingMatchesWith:)`` and how the returned search results can be used to perform a replace operation. - /// - /// ```swift - /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) - /// let results = textView.search(for: query, replacingMatchesWith: "bar") - /// let replacements = results.map { BatchReplaceSet.Replacement(range: $0.range, text: $0.replacementText) } - /// let batchReplaceSet = BatchReplaceSet(replacements: replacements) - /// textView.replaceText(in: batchReplaceSet) - /// ``` - /// - /// - Parameters: - /// - query: Query to find matches for. - /// - replacementString: String to replace matches with. Can refer to groups in a regular expression using $0, $1, $2 etc. - /// - Returns: Results matching the query. - public func search(for query: SearchQuery, replacingMatchesWith replacementString: String) -> [SearchReplaceResult] { - let searchController = SearchController(stringView: textInputView.stringView) - searchController.delegate = self - return searchController.search(for: query, replacingMatchesWith: replacementString) - } - - /// Returns a peek into the text view's underlying attributed string. - /// - Parameter range: Range of text to include in text view. The returned result may span a larger range than the one specified. - /// - Returns: Text preview containing the specified range. - public func textPreview(containing range: NSRange) -> TextPreview? { - return textInputView.textPreview(containing: range) - } - - /// Selects a highlighted range behind the selected range if possible. - public func selectPreviousHighlightedRange() { - highlightNavigationController.selectPreviousRange() - } - - /// Selects a highlighted range after the selected range if possible. - public func selectNextHighlightedRange() { - highlightNavigationController.selectNextRange() - } - - /// Selects the highlighed range at the specified index. - /// - Parameter index: Index of highlighted range to select. - public func selectHighlightedRange(at index: Int) { - highlightNavigationController.selectRange(at: index) - } - - /// Synchronously displays the visible lines. This can be used to immediately update the visible lines after setting the theme. Use with caution as this redisplaying the visible lines can be a costly operation. - public func redisplayVisibleLines() { - textInputView.redisplayVisibleLines() - } -} - -// MARK: - UITextInput -extension TextView { - /// The range of currently marked text in a document. - public var markedTextRange: UITextRange? { - return textInputView.markedTextRange - } - - /// The text position for the beginning of a document. - public var beginningOfDocument: UITextPosition { - return textInputView.beginningOfDocument - } - - /// The text position for the end of a document. - public var endOfDocument: UITextPosition { - return textInputView.endOfDocument - } - - /// Returns the range between two text positions. - /// - Parameters: - /// - fromPosition: An object that represents a location in a document. - /// - toPosition: An object that represents another location in a document. - /// - Returns: An object that represents the range between fromPosition and toPosition. - public func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { - return textInputView.textRange(from: fromPosition, to: toPosition) - } - - /// Returns the text position at a specified offset from another text position. - /// - Parameters: - /// - position: A custom UITextPosition object that represents a location in a document. - /// - offset: A character offset from position. It can be a positive or negative value. - /// - Returns: A custom UITextPosition object that represents the location in a document that is at the specified offset from position. Returns nil if the computed text position is less than 0 or greater than the length of the backing string. - public func position(from position: UITextPosition, offset: Int) -> UITextPosition? { - return textInputView.position(from: position, offset: offset) - } - - /// Returns the text position at a specified offset in a specified direction from another text position. - /// - Parameters: - /// - position: A custom UITextPosition object that represents a location in a document. - /// - direction: A UITextLayoutDirection constant that represents the direction of the offset from position. - /// - offset: A character offset from position. - /// - Returns: Returns the text position at a specified offset in a specified direction from another text position. Returns nil if the computed text position is less than 0 or greater than the length of the backing string. - public func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? { - return textInputView.position(from: position, in: direction, offset: offset) - } - - /// Returns how one text position compares to another text position. - /// - Parameters: - /// - position: A custom object that represents a location within a document. - /// - other: A custom object that represents another location within a document. - /// - Returns: A value that indicates whether the two text positions are identical or whether one is before the other. - public func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { - return textInputView.compare(position, to: other) - } - - /// Returns the number of UTF-16 characters between one text position and another text position. - /// - Parameters: - /// - from: A custom object that represents a location within a document. - /// - toPosition: A custom object that represents another location within document. - /// - Returns: The number of UTF-16 characters between fromPosition and toPosition. - public func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { - return textInputView.offset(from: from, to: toPosition) - } - - /// An input tokenizer that provides information about the granularity of text units. - public var tokenizer: UITextInputTokenizer { - return textInputView.tokenizer - } - - /// Returns the text position that is at the farthest extent in a specified layout direction within a range of text. - /// - Parameters: - /// - range: A text-range object that demarcates a range of text in a document. - /// - direction: A constant that indicates a direction of layout (right, left, up, down). - /// - Returns: A text-position object that identifies a location in the visible text. - public func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { - return textInputView.position(within: range, farthestIn: direction) - } - - /// Returns a text range from a specified text position to its farthest extent in a certain direction of layout. - /// - Parameters: - /// - position: A text-position object that identifies a location in a document. - /// - direction: A constant that indicates a direction of layout (right, left, up, down). - /// - Returns: A text-range object that represents the distance from position to the farthest extent in direction. - public func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { - return textInputView.characterRange(byExtending: position, in: direction) - } - - /// Returns the first rectangle that encloses a range of text in a document. - /// - Parameter range: An object that represents a range of text in a document. - /// - Returns: The first rectangle in a range of text. You might use this rectangle to draw a correction rectangle. The “first” in the name refers the rectangle enclosing the first line when the range encompasses multiple lines of text. - public func firstRect(for range: UITextRange) -> CGRect { - return textInputView.firstRect(for: range) - } - - /// Returns a rectangle to draw the caret at a specified insertion point. - /// - Parameter position: An object that identifies a location in a text input area. - /// - Returns: A rectangle that defines the area for drawing the caret. - public func caretRect(for position: UITextPosition) -> CGRect { - return textInputView.caretRect(for: position) - } - - /// Returns an array of selection rects corresponding to the range of text. - /// - Parameter range: An object representing a range in a document’s text. - /// - Returns: An array of UITextSelectionRect objects that encompass the selection. - public func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { - return textInputView.selectionRects(for: range) - } - - /// Returns the position in a document that is closest to a specified point. - /// - Parameter point: A point in the view that is drawing a document’s text. - /// - Returns: An object locating a position in a document that is closest to point. - public func closestPosition(to point: CGPoint) -> UITextPosition? { - return textInputView.closestPosition(to: point) - } - - /// Returns the position in a document that is closest to a specified point in a specified range. - /// - Parameters: - /// - point: A point in the view that is drawing a document’s text. - /// - range: An object representing a range in a document’s text. - /// - Returns: An object representing the character position in range that is closest to point. - public func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { - return textInputView.closestPosition(to: point, within: range) - } - - /// Returns the character or range of characters that is at a specified point in a document. - /// - Parameter point: A point in the view that is drawing a document’s text. - /// - Returns: An object representing a range that encloses a character (or characters) at point. - public func characterRange(at point: CGPoint) -> UITextRange? { - return textInputView.characterRange(at: point) - } - - /// Returns the text in the specified range. - /// - Parameter range: A range of text in a document. - /// - Returns: A substring of a document that falls within the specified range. - public func text(in range: UITextRange) -> String? { - return textInputView.text(in: range) - } - - /// A Boolean value that indicates whether the text-entry object has any text. - public var hasText: Bool { - return textInputView.hasText - } - - /// Scrolls the text view to reveal the text in the specified range. - /// - /// The function will scroll the text view as little as possible while revealing as much as possible of the specified range. It is not guaranteed that the entire range is visible after performing the scroll. - /// - /// - Parameters: - /// - range: The range of text to scroll into view. - public func scrollRangeToVisible(_ range: NSRange) { - textInputView.layoutLines(toLocation: range.upperBound) - justScrollRangeToVisible(range) - } -} - -private extension TextView { - @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard isSelectable else { - return - } - if gestureRecognizer.state == .ended { - let point = gestureRecognizer.location(in: textInputView) - let oldSelectedRange = textInputView.selectedRange - textInputView.moveCaret(to: point) - if textInputView.selectedRange != oldSelectedRange { - layoutIfNeeded() - } - installEditableInteraction() - becomeFirstResponder() - } - } - - @objc private func handleTextRangeAdjustmentPan(_ gestureRecognizer: UIPanGestureRecognizer) { - // This function scroll the text view when the selected range is adjusted. - if gestureRecognizer.state == .began { - previousSelectedRangeDuringGestureHandling = selectedRange - } else if gestureRecognizer.state == .changed, let previousSelectedRange = previousSelectedRangeDuringGestureHandling { - if selectedRange.lowerBound != previousSelectedRange.lowerBound { - // User is adjusting the lower bound (location) of the selected range. - scrollLocationToVisible(selectedRange.lowerBound) - } else if selectedRange.upperBound != previousSelectedRange.upperBound { - // User is adjusting the upper bound (length) of the selected range. - scrollLocationToVisible(selectedRange.upperBound) - } - previousSelectedRangeDuringGestureHandling = selectedRange - } - } - - private func insertLeadingComponent(of characterPair: CharacterPair, in range: NSRange) -> Bool { - let shouldInsertCharacterPair = editorDelegate?.textView(self, shouldInsert: characterPair, in: range) ?? true - guard shouldInsertCharacterPair else { - return false - } - guard let selectedRange = textInputView.selectedRange else { - return false - } - if selectedRange.length == 0 { - textInputView.insertText(characterPair.leading + characterPair.trailing) - textInputView.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: 0) - return true - } else if let text = textInputView.text(in: selectedRange) { - let modifiedText = characterPair.leading + text + characterPair.trailing - let indexedRange = IndexedRange(selectedRange) - textInputView.replace(indexedRange, withText: modifiedText) - textInputView.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: range.length) - return true - } else { - return false - } - } - - private func skipInsertingTrailingComponent(of characterPair: CharacterPair, in range: NSRange) -> Bool { - // When typing the trailing component of a character pair, e.g. ) or } and the cursor is just in front of that character, - // the delegate is asked whether the text view should skip inserting that character. If the character is skipped, - // then the caret is moved after the trailing character component. - let followingTextRange = NSRange(location: range.location + range.length, length: characterPair.trailing.count) - let followingText = textInputView.text(in: followingTextRange) - guard followingText == characterPair.trailing else { - return false - } - let shouldSkip = editorDelegate?.textView(self, shouldSkipTrailingComponentOf: characterPair, in: range) ?? true - if shouldSkip { - moveCaret(byOffset: characterPair.trailing.count) - return true - } else { - return false - } - } - - private func moveCaret(byOffset offset: Int) { - if let selectedRange = textInputView.selectedRange { - textInputView.selectedRange = NSRange(location: selectedRange.location + offset, length: 0) - } - } - - private func handleContentSizeUpdateIfNeeded() { - if hasPendingContentSizeUpdate { - // We don't want to update the content size when the scroll view is "bouncing" near the gutter, - // or at the end of a line since it causes flickering when updating the content size while scrolling. - // However, we do allow updating the content size if the text view is scrolled far enough on - // the y-axis as that means it will soon run out of text to display. - let isBouncingAtGutter = contentOffset.x < -contentInset.left - let isBouncingAtLineEnd = contentOffset.x > contentSize.width - frame.size.width + contentInset.right - let isBouncingHorizontally = isBouncingAtGutter || isBouncingAtLineEnd - let isCriticalUpdate = contentOffset.y > contentSize.height - frame.height * 1.5 - let isScrolling = isDragging || isDecelerating - if !isBouncingHorizontally || isCriticalUpdate || !isScrolling { - hasPendingContentSizeUpdate = false - let oldContentOffset = contentOffset - contentSize = preferredContentSize - contentOffset = oldContentOffset - setNeedsLayout() - } - } - } - - private func justScrollRangeToVisible(_ range: NSRange) { - let lowerBoundRect = textInputView.caretRect(at: range.lowerBound) - let upperBoundRect = range.length == 0 ? lowerBoundRect : textInputView.caretRect(at: range.upperBound) - let rectMinX = min(lowerBoundRect.minX, upperBoundRect.minX) - let rectMaxX = max(lowerBoundRect.maxX, upperBoundRect.maxX) - let rectMinY = min(lowerBoundRect.minY, upperBoundRect.minY) - let rectMaxY = max(lowerBoundRect.maxY, upperBoundRect.maxY) - let rect = CGRect(x: rectMinX, y: rectMinY, width: rectMaxX - rectMinX, height: rectMaxY - rectMinY) - contentOffset = contentOffsetForScrollingToVisibleRect(rect) - } - - private func scrollLocationToVisible(_ location: Int) { - let range = NSRange(location: location, length: 0) - justScrollRangeToVisible(range) - } - - private func installEditableInteraction() { - if editableTextInteraction.view == nil { - isInputAccessoryViewEnabled = true - textInputView.removeInteraction(nonEditableTextInteraction) - textInputView.addInteraction(editableTextInteraction) - } - } - - private func installNonEditableInteraction() { - if nonEditableTextInteraction.view == nil { - isInputAccessoryViewEnabled = false - textInputView.removeInteraction(editableTextInteraction) - textInputView.addInteraction(nonEditableTextInteraction) - for gestureRecognizer in nonEditableTextInteraction.gesturesForFailureRequirements { - gestureRecognizer.require(toFail: tapGestureRecognizer) - } - } - } - - /// Computes a content offset to scroll to in order to reveal the specified rectangle. - /// - /// The function will return a rectangle that scrolls the text view a minimum amount while revealing as much as possible of the rectangle. It is not guaranteed that the entire rectangle can be revealed. - /// - Parameter rect: The rectangle to reveal. - /// - Returns: The content offset to scroll to. - private func contentOffsetForScrollingToVisibleRect(_ rect: CGRect) -> CGPoint { - // Create the viewport: a rectangle containing the content that is visible to the user. - var viewport = CGRect(x: contentOffset.x, y: contentOffset.y, width: frame.width, height: frame.height) - viewport.origin.y += adjustedContentInset.top - viewport.origin.x += adjustedContentInset.left + gutterWidth - viewport.size.width -= adjustedContentInset.left + adjustedContentInset.right + gutterWidth - viewport.size.height -= adjustedContentInset.top + adjustedContentInset.bottom - // Construct the best possible content offset. - var newContentOffset = contentOffset - if rect.minX < viewport.minX { - newContentOffset.x -= viewport.minX - rect.minX - } else if rect.maxX > viewport.maxX && rect.width <= viewport.width { - // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. - newContentOffset.x += rect.maxX - viewport.maxX - } else if rect.maxX > viewport.maxX { - newContentOffset.x += rect.minX - } - if rect.minY < viewport.minY { - newContentOffset.y -= viewport.minY - rect.minY - } else if rect.maxY > viewport.maxY && rect.height <= viewport.height { - // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. - newContentOffset.y += rect.maxY - viewport.maxY - } else if rect.maxY > viewport.maxY { - newContentOffset.y += rect.minY - } - let cappedXOffset = min(max(newContentOffset.x, minimumContentOffset.x), maximumContentOffset.x) - let cappedYOffset = min(max(newContentOffset.y, minimumContentOffset.y), maximumContentOffset.y) - return CGPoint(x: cappedXOffset, y: cappedYOffset) - } -} - -// MARK: - TextInputViewDelegate -extension TextView: TextInputViewDelegate { - func textInputViewWillBeginEditing(_ view: TextInputView) { - guard isEditable else { - return - } - isEditing = !isPerformingNonEditableTextInteraction - // If a developer is programmatically calling becomeFirstresponder() then we might not have a selected range. - // We set the selectedRange instead of the selectedTextRange to avoid invoking any delegates. - if textInputView.selectedRange == nil && !isPerformingNonEditableTextInteraction { - textInputView.selectedRange = NSRange(location: 0, length: 0) - } - // Ensure selection is laid out without animation. - UIView.performWithoutAnimation { - textInputView.layoutIfNeeded() - } - // The editable interaction must be installed early in the -becomeFirstResponder() call - if !isPerformingNonEditableTextInteraction { - installEditableInteraction() - } - } - - func textInputViewDidBeginEditing(_ view: TextInputView) { - if !isPerformingNonEditableTextInteraction { - editorDelegate?.textViewDidBeginEditing(self) - } - } - - func textInputViewDidCancelBeginEditing(_ view: TextInputView) { - isEditing = false - installNonEditableInteraction() - } - - func textInputViewDidEndEditing(_ view: TextInputView) { - isEditing = false - installNonEditableInteraction() - editorDelegate?.textViewDidEndEditing(self) - } - - func textInputViewDidChange(_ view: TextInputView) { - if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { - scrollLocationToVisible(newRange.location) - } - editorDelegate?.textViewDidChange(self) - } - - func textInputViewDidChangeSelection(_ view: TextInputView) { - UIMenuController.shared.hideMenu(from: self) - highlightNavigationController.selectedRange = view.selectedRange - if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { - scrollLocationToVisible(newRange.location) - } - editorDelegate?.textViewDidChangeSelection(self) - } - - func textInputViewDidInvalidateContentSize(_ view: TextInputView) { - if contentSize != view.contentSize { - hasPendingContentSizeUpdate = true - handleContentSizeUpdateIfNeeded() - } - } - - func textInputView(_ view: TextInputView, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) { - let isScrolling = isDragging || isDecelerating - if contentOffsetAdjustment != .zero && isScrolling { - contentOffset = CGPoint(x: contentOffset.x + contentOffsetAdjustment.x, y: contentOffset.y + contentOffsetAdjustment.y) - } - } - - func textInputView(_ view: TextInputView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - if textInputView.isRestoringPreviouslyDeletedText { - // UIKit is inserting text to combine characters, for example to combine two Korean characters into one, and we do not want to interfere with that. - return editorDelegate?.textView(self, shouldChangeTextIn: range, replacementText: text) ?? true - } else if let characterPair = characterPairs.first(where: { $0.trailing == text }), - skipInsertingTrailingComponent(of: characterPair, in: range) { - return false - } else if let characterPair = characterPairs.first(where: { $0.leading == text }), insertLeadingComponent(of: characterPair, in: range) { - return false - } else { - return editorDelegate?.textView(self, shouldChangeTextIn: range, replacementText: text) ?? true - } - } - - func textInputViewDidChangeGutterWidth(_ view: TextInputView) { - editorDelegate?.textViewDidChangeGutterWidth(self) - } - - func textInputViewDidBeginFloatingCursor(_ view: TextInputView) { - editorDelegate?.textViewDidBeginFloatingCursor(self) - } - - func textInputViewDidEndFloatingCursor(_ view: TextInputView) { - editorDelegate?.textViewDidEndFloatingCursor(self) - } - - func textInputViewDidUpdateMarkedRange(_ view: TextInputView) { - // There seems to be a bug in UITextInput (or UITextInteraction?) where updating the markedTextRange of a UITextInput - // will cause the caret to disappear. Removing the editable text interaction and adding it back will work around this issue. - DispatchQueue.main.async { - if !view.viewHierarchyContainsCaret && self.editableTextInteraction.view != nil { - view.removeInteraction(self.editableTextInteraction) - view.addInteraction(self.editableTextInteraction) - } - } - } - - func textInputView(_ view: TextInputView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - return editorDelegate?.textView(self, canReplaceTextIn: highlightedRange) ?? false - } - - func textInputView(_ view: TextInputView, replaceTextIn highlightedRange: HighlightedRange) { - editorDelegate?.textView(self, replaceTextIn: highlightedRange) - } -} - -// MARK: - HighlightNavigationControllerDelegate -extension TextView: HighlightNavigationControllerDelegate { - func highlightNavigationController(_ controller: HighlightNavigationController, - shouldNavigateTo highlightNavigationRange: HighlightNavigationRange) { - let range = highlightNavigationRange.range - scrollRangeToVisible(range) - textInputView.selectedTextRange = IndexedRange(range) - _ = textInputView.becomeFirstResponder() - if showMenuAfterNavigatingToHighlightedRange { - textInputView.presentEditMenuForText(in: range) - } - switch highlightNavigationRange.loopMode { - case .previousGoesToLast: - editorDelegate?.textViewDidLoopToLastHighlightedRange(self) - case .nextGoesToFirst: - editorDelegate?.textViewDidLoopToFirstHighlightedRange(self) - case .disabled: - break - } - } -} - -// MARK: - SearchControllerDelegate -extension TextView: SearchControllerDelegate { - func searchController(_ searchController: SearchController, linePositionAt location: Int) -> LinePosition? { - return textInputView.lineManager.linePosition(at: location) - } -} - -// MARK: - UIGestureRecognizerDelegate -extension TextView: UIGestureRecognizerDelegate { - override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer === tapGestureRecognizer { - return !isEditing && !isDragging && !isDecelerating && delegateAllowsEditingToBegin - } else { - return super.gestureRecognizerShouldBegin(gestureRecognizer) - } - } - - public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - if let klass = NSClassFromString("UITextRangeAdjustmentGestureRecognizer") { - if !textRangeAdjustmentGestureRecognizers.contains(otherGestureRecognizer) && otherGestureRecognizer.isKind(of: klass) { - otherGestureRecognizer.addTarget(self, action: #selector(handleTextRangeAdjustmentPan(_:))) - textRangeAdjustmentGestureRecognizers.insert(otherGestureRecognizer) - } - } - return gestureRecognizer !== panGestureRecognizer - } -} - -// MARK: - KeyboardObserverDelegate -extension TextView: KeyboardObserverDelegate { - func keyboardObserver(_ keyboardObserver: KeyboardObserver, - keyboardWillShowWithHeight keyboardHeight: CGFloat, - animation: KeyboardObserver.Animation?) { - if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { - scrollRangeToVisible(newRange) - } - } -} - -// MARK: - UITextInteractionDelegate -extension TextView: UITextInteractionDelegate { - public func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { - if interaction.textInteractionMode == .editable { - return isEditable - } else if interaction.textInteractionMode == .nonEditable { - // The private UITextLoupeInteraction and UITextNonEditableInteractionclass will end up in this case. The latter is likely created from UITextInteraction(for: .nonEditable) but we want to disable both when selection is disabled. - return isSelectable - } else { - return true - } - } - - public func interactionWillBegin(_ interaction: UITextInteraction) { - if interaction.textInteractionMode == .nonEditable { - // When long-pressing our instance of UITextInput, the UITextInteraction will make the text input first responder. - // In this case the user wants to select text in the text view but not start editing, so we set a flag that tells us - // that we should not install editable text interaction in this case. - isPerformingNonEditableTextInteraction = true - } - } - - public func interactionDidEnd(_ interaction: UITextInteraction) { - if interaction.textInteractionMode == .nonEditable { - isPerformingNonEditableTextInteraction = false - } - } -} -#endif From 2e4defc0ec2cae787ba56793bb3a7be84aa4aeeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 10:13:17 +0100 Subject: [PATCH 014/232] Adds constant.builtin --- Example/Themes/Sources/RunestoneOneDarkTheme/OneDarkTheme.swift | 2 +- Example/Themes/Sources/RunestoneThemeCommon/HighlightName.swift | 1 + .../Themes/Sources/RunestoneTomorrowTheme/TomorrowTheme.swift | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Example/Themes/Sources/RunestoneOneDarkTheme/OneDarkTheme.swift b/Example/Themes/Sources/RunestoneOneDarkTheme/OneDarkTheme.swift index 2b5cc0201..4d721626d 100644 --- a/Example/Themes/Sources/RunestoneOneDarkTheme/OneDarkTheme.swift +++ b/Example/Themes/Sources/RunestoneOneDarkTheme/OneDarkTheme.swift @@ -48,7 +48,7 @@ public final class OneDarkTheme: EditorTheme { return UIColor(namedInModule: "OneDarkYellow") case .keyword: return UIColor(namedInModule: "OneDarkPurple") - case .variableBuiltin: + case .variableBuiltin, .constantBuiltin: return UIColor(namedInModule: "OneDarkRed") } } diff --git a/Example/Themes/Sources/RunestoneThemeCommon/HighlightName.swift b/Example/Themes/Sources/RunestoneThemeCommon/HighlightName.swift index e47b1bc64..81ed6f796 100644 --- a/Example/Themes/Sources/RunestoneThemeCommon/HighlightName.swift +++ b/Example/Themes/Sources/RunestoneThemeCommon/HighlightName.swift @@ -14,6 +14,7 @@ public enum HighlightName: String { case punctuation case string case variableBuiltin = "variable.builtin" + case constantBuiltin = "constant.builtin" public init?(_ rawHighlightName: String) { var comps = rawHighlightName.split(separator: ".") diff --git a/Example/Themes/Sources/RunestoneTomorrowTheme/TomorrowTheme.swift b/Example/Themes/Sources/RunestoneTomorrowTheme/TomorrowTheme.swift index b410d6e19..ab3878f9f 100644 --- a/Example/Themes/Sources/RunestoneTomorrowTheme/TomorrowTheme.swift +++ b/Example/Themes/Sources/RunestoneTomorrowTheme/TomorrowTheme.swift @@ -48,7 +48,7 @@ public final class TomorrowTheme: EditorTheme { return UIColor(namedInModule: "TomorrowOrange") case .keyword: return UIColor(namedInModule: "TomorrowPurple") - case .variableBuiltin: + case .variableBuiltin, .constantBuiltin: return UIColor(namedInModule: "TomorrowRed") } } From f7a956c4a8915ce8483b7813178462393a305ca4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 10:13:49 +0100 Subject: [PATCH 015/232] Moves TextView to TextView_iOS --- ...t.swift => TextView_iOS+UITextInput.swift} | 72 +++++++++++---- .../TextView_iOS.swift} | 91 +++++++++++++++++-- 2 files changed, 136 insertions(+), 27 deletions(-) rename Sources/Runestone/TextView/Core/iOS/{TextView+UITextInput.swift => TextView_iOS+UITextInput.swift} (85%) rename Sources/Runestone/TextView/Core/{TextView.swift => iOS/TextView_iOS.swift} (92%) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift similarity index 85% rename from Sources/Runestone/TextView/Core/iOS/TextView+UITextInput.swift rename to Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index a336f4724..4492cce47 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -29,6 +29,47 @@ public extension TextView { allowMovingCaretToNextLineFragment: true ) } + + func beginFloatingCursor(at point: CGPoint) { + guard floatingCaretView == nil, let position = closestPosition(to: point) else { + return + } + insertionPointColorBeforeFloatingBegan = insertionPointColor + insertionPointColor = insertionPointColorBeforeFloatingBegan.withAlphaComponent(0.5) + updateCaretColor() + let caretRect = self.caretRect(for: position) + let caretOrigin = CGPoint(x: point.x - caretRect.width / 2, y: point.y - caretRect.height / 2) + let floatingCaretView = FloatingCaretView() + floatingCaretView.backgroundColor = insertionPointColorBeforeFloatingBegan + floatingCaretView.frame = CGRect(origin: caretOrigin, size: caretRect.size) + addSubview(floatingCaretView) + self.floatingCaretView = floatingCaretView + editorDelegate?.textViewDidBeginFloatingCursor(self) + } + + func updateFloatingCursor(at point: CGPoint) { + if let floatingCaretView = floatingCaretView { + let caretSize = floatingCaretView.frame.size + let caretOrigin = CGPoint(x: point.x - caretSize.width / 2, y: point.y - caretSize.height / 2) + floatingCaretView.frame = CGRect(origin: caretOrigin, size: caretSize) + } + } + + func endFloatingCursor() { + insertionPointColor = insertionPointColorBeforeFloatingBegan + updateCaretColor() + floatingCaretView?.removeFromSuperview() + floatingCaretView = nil + editorDelegate?.textViewDidEndFloatingCursor(self) + } + + func updateCaretColor() { + // Removing the UITextSelectionView and re-adding it forces it to query the insertion point color. + if let textSelectionView = textSelectionView { + textSelectionView.removeFromSuperview() + addSubview(textSelectionView) + } + } } // MARK: - Editing @@ -42,26 +83,25 @@ public extension TextView { } func replace(_ range: UITextRange, withText text: String) { - let preparedText = prepareTextForInsertion(text) - if let indexedRange = range as? IndexedRange, shouldChangeText(in: indexedRange.range.nonNegativeLength, replacementText: preparedText) { - textViewController.replaceText(in: indexedRange.range.nonNegativeLength, with: preparedText) - handleTextSelectionChange() + let preparedText = textViewController.prepareTextForInsertion(text) + guard let indexedRange = range as? IndexedRange else { + return } + guard textViewController.shouldChangeText(in: indexedRange.range.nonNegativeLength, replacementText: preparedText) else { + return + } + textViewController.replaceText(in: indexedRange.range.nonNegativeLength, with: preparedText) + handleTextSelectionChange() } func insertText(_ text: String) { - let preparedText = prepareTextForInsertion(text) isRestoringPreviouslyDeletedText = hasDeletedTextWithPendingLayoutSubviews hasDeletedTextWithPendingLayoutSubviews = false defer { isRestoringPreviouslyDeletedText = false } - // If there is no marked range or selected range then we fallback to appending text to the end of our string. - let selectedRange = textViewController.markedRange - ?? textViewController.selectedRange - ?? NSRange(location: textViewController.stringView.string.length, length: 0) - guard shouldChangeText(in: selectedRange, replacementText: preparedText) else { - isRestoringPreviouslyDeletedText = false + let preparedText = textViewController.prepareTextForInsertion(text) + guard textViewController.shouldChangeText(in: selectedRange, replacementText: preparedText) else { return } // If we're inserting text then we can't have a marked range. However, UITextInput doesn't always clear the marked range @@ -71,13 +111,11 @@ public extension TextView { textViewController.markedRange = nil if LineEnding(symbol: text) != nil { textViewController.indentController.insertLineBreak(in: selectedRange, using: lineEndings) - layoutIfNeeded() - handleTextSelectionChange() } else { textViewController.replaceText(in: selectedRange, with: preparedText) - layoutIfNeeded() - handleTextSelectionChange() } + layoutIfNeeded() + handleTextSelectionChange() } func deleteBackward() { @@ -90,7 +128,7 @@ public extension TextView { if deleteRange == textViewController.markedRange { textViewController.markedRange = nil } - guard shouldChangeText(in: deleteRange, replacementText: "") else { + guard textViewController.shouldChangeText(in: deleteRange, replacementText: "") else { return } // Set a flag indicating that we have deleted text. This is reset in -layoutSubviews() but if this has not been reset before insertText() is called, then UIKit deleted characters prior to inserting combined characters. This happens when UIKit turns Korean characters into a single character. E.g. when typing ㅇ followed by ㅓ UIKit will perform the following operations: @@ -199,7 +237,7 @@ public extension TextView { return } let markedText = markedText ?? "" - guard shouldChangeText(in: range, replacementText: markedText) else { + guard textViewController.shouldChangeText(in: range, replacementText: markedText) else { return } textViewController.markedRange = markedText.isEmpty ? nil : NSRange(location: range.location, length: markedText.utf16.count) diff --git a/Sources/Runestone/TextView/Core/TextView.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift similarity index 92% rename from Sources/Runestone/TextView/Core/TextView.swift rename to Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 66c71fd1b..4e015adfb 100644 --- a/Sources/Runestone/TextView/Core/TextView.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -521,7 +521,8 @@ open class TextView: UIScrollView { private var isInputAccessoryViewEnabled = false private var _inputAccessoryView: UIView? private let tapGestureRecognizer = QuickTapGestureRecognizer() - + var floatingCaretView: FloatingCaretView? + var insertionPointColorBeforeFloatingBegan: UIColor = .label // Store a reference to instances of the private type UITextRangeAdjustmentGestureRecognizer in order to track adjustments // to the selected text range and scroll the text view when the handles approach the bottom. // The approach is based on the one described in Steve Shephard's blog post "Adventures with UITextInteraction". @@ -655,7 +656,7 @@ open class TextView: UIScrollView { open override func paste(_ sender: Any?) { if let selectedTextRange = selectedTextRange, let string = UIPasteboard.general.string { inputDelegate?.selectionWillChange(self) - let preparedText = prepareTextForInsertion(string) + let preparedText = textViewController.prepareTextForInsertion(string) replace(selectedTextRange, withText: preparedText) inputDelegate?.selectionDidChange(self) } @@ -934,6 +935,40 @@ open class TextView: UIScrollView { public func scrollRangeToVisible(_ range: NSRange) { textViewController.scrollRangeToVisible(range) } + + /// Called when the iOS interface environment changes. + open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { + textViewController.invalidateLines() + textViewController.layoutManager.setNeedsLayout() + } + } + + /// Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point. + /// - Parameters: + /// - point: A point specified in the receiver’s local coordinate system (bounds). + /// - event: The event that warranted a call to this method. If you are calling this method from outside your event-handling code, you may specify nil. + /// - Returns: The view object that is the farthest descendent of the current view and contains point. Returns nil if the point lies completely outside the receiver’s view hierarchy. + open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + // We end our current undo group when the user touches the view. + let result = super.hitTest(point, with: event) + if result === self { + undoManager?.endUndoGrouping() + } + return result + } + + /// Tells the object when a button is released. + /// - Parameters: + /// - presses: A set of UIPress instances that represent the buttons that the user is no longer pressing. + /// - event: The event to which the presses belong. + open override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + super.pressesEnded(presses, with: event) + if let keyCode = presses.first?.key?.keyCode, presses.count == 1, textViewController.markedRange != nil { + handleKeyPressDuringMultistageTextInput(keyCode: keyCode) + } + } } extension TextView { @@ -1017,14 +1052,6 @@ private extension TextView { installNonEditableInteraction() editorDelegate?.textViewDidEndEditing(self) } - - private func updateCaretColor() { - // Removing the UITextSelectionView and re-adding it forces it to query the insertion point color. - if let textSelectionView = textSelectionView { - textSelectionView.removeFromSuperview() - addSubview(textSelectionView) - } - } private func installEditableInteraction() { if editableTextInteraction.view == nil { @@ -1082,6 +1109,50 @@ private extension TextView { editorDelegate?.textView(self, replaceTextIn: highlightedRange) } } + + 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 + // on macOS and I think that works quite well for plain text editors on iOS too. + guard let markedRange = textViewController.markedRange, let markedText = textViewController.stringView.substring(in: markedRange) else { + return + } + // We only unmark the text if the marked text contains specific characters only. + // Some languages use multistage text input extensively and for those iOS presents a UI when + // navigating with the arrow keys. We do not want to interfere with that interaction. + let characterSet = CharacterSet(charactersIn: "`´^¨") + guard markedText.rangeOfCharacter(from: characterSet.inverted) == nil else { + return + } + switch keyCode { + case .keyboardUpArrow: + moveCaret(.up) + unmarkText() + case .keyboardRightArrow: + moveCaret(.right) + unmarkText() + case .keyboardDownArrow: + moveCaret(.down) + unmarkText() + case .keyboardLeftArrow: + moveCaret(.left) + unmarkText() + case .keyboardEscape: + unmarkText() + default: + break + } + } + + private func moveCaret(_ direction: UITextLayoutDirection) { + guard let selectedRange = textViewController.selectedRange else { + return + } + guard let location = textViewController.lineMovementController.location(from: selectedRange.location, in: direction, offset: 1) else { + return + } + self.selectedRange = NSRange(location: location, length: 0) + } } //// MARK: - TextInputViewDelegate From 91a76323873951b1f748e212b4b5580880f9ddfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 10:13:59 +0100 Subject: [PATCH 016/232] Improves formatting --- .../Core/iOS/LineMovementController.swift | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift b/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift index 9077942ab..aaeee34f6 100644 --- a/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift +++ b/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift @@ -69,10 +69,12 @@ 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 { @@ -90,10 +92,12 @@ 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 { @@ -113,10 +117,12 @@ private extension LineMovementController { 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 bef22afd5b8fc7b85c0839b2ccc0b0a60ca4d2ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 10:14:25 +0100 Subject: [PATCH 017/232] Removes TextInputView --- .../TextView/Core/iOS/TextInputView.swift | 1672 ----------------- 1 file changed, 1672 deletions(-) delete mode 100644 Sources/Runestone/TextView/Core/iOS/TextInputView.swift diff --git a/Sources/Runestone/TextView/Core/iOS/TextInputView.swift b/Sources/Runestone/TextView/Core/iOS/TextInputView.swift deleted file mode 100644 index 506413ff3..000000000 --- a/Sources/Runestone/TextView/Core/iOS/TextInputView.swift +++ /dev/null @@ -1,1672 +0,0 @@ -#if os(iOS) -// swiftlint:disable file_length -import Combine -import UIKit - -protocol TextInputViewDelegate: AnyObject { - func textInputViewWillBeginEditing(_ view: TextInputView) - func textInputViewDidBeginEditing(_ view: TextInputView) - func textInputViewDidEndEditing(_ view: TextInputView) - func textInputViewDidCancelBeginEditing(_ view: TextInputView) - func textInputViewDidChange(_ view: TextInputView) - func textInputViewDidChangeSelection(_ view: TextInputView) - func textInputView(_ view: TextInputView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool - func textInputViewDidInvalidateContentSize(_ view: TextInputView) - func textInputView(_ view: TextInputView, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) - func textInputViewDidChangeGutterWidth(_ view: TextInputView) - func textInputViewDidBeginFloatingCursor(_ view: TextInputView) - func textInputViewDidEndFloatingCursor(_ view: TextInputView) - func textInputViewDidUpdateMarkedRange(_ view: TextInputView) - func textInputView(_ view: TextInputView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool - func textInputView(_ view: TextInputView, replaceTextIn highlightedRange: HighlightedRange) -} - -// swiftlint:disable:next type_body_length -final class TextInputView: UIView, UITextInput { - // MARK: - UITextInput - var selectedTextRange: UITextRange? { - get { - if let range = _selectedRange { - return IndexedRange(range) - } else { - return nil - } - } - set { - // We should not use this setter. It's intended for UIKit to use. It'll invoke the setter in various scenarios, for example when navigating the text using the keyboard. - // On the iOS 16 beta, UIKit may pass an NSRange with a negatives length (e.g. {4, -2}) when double tapping to select text. This will cause a crash when UIKit later attempts to use the selected range with NSString's -substringWithRange:. This can be tested with a string containing the following three lines: - // A - // - // A - // Placing the character on the second line, which is empty, and double tapping several times on the empty line to select text will cause the editor to crash. To work around this we take the non-negative value of the selected range. Last tested on August 30th, 2022. - let newRange = (newValue as? IndexedRange)?.range.nonNegativeLength - if newRange != _selectedRange { - notifyDelegateAboutSelectionChangeInLayoutSubviews = 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 - } - notifyInputDelegateAboutSelectionChangeInLayoutSubviews = !shouldNotifyInputDelegate - if shouldNotifyInputDelegate { - inputDelegate?.selectionWillChange(self) - } - _selectedRange = newRange - if shouldNotifyInputDelegate { - inputDelegate?.selectionDidChange(self) - } - } - } - } - private(set) var markedTextRange: UITextRange? { - get { - if let markedRange = markedRange { - return IndexedRange(markedRange) - } else { - return nil - } - } - set { - markedRange = (newValue as? IndexedRange)?.range.nonNegativeLength - } - } - var markedTextStyle: [NSAttributedString.Key: Any]? - var beginningOfDocument: UITextPosition { - return IndexedPosition(index: 0) - } - var endOfDocument: UITextPosition { - return IndexedPosition(index: string.length) - } - weak var inputDelegate: UITextInputDelegate? - var hasText: Bool { - return string.length > 0 - } - var tokenizer: UITextInputTokenizer { - return customTokenizer - } - private lazy var customTokenizer = TextInputStringTokenizer(textInput: self, - stringView: stringView, - lineManager: lineManager, - lineControllerStorage: lineControllerStorage) - var autocorrectionType: UITextAutocorrectionType = .default - var autocapitalizationType: UITextAutocapitalizationType = .sentences - var smartQuotesType: UITextSmartQuotesType = .default - var smartDashesType: UITextSmartDashesType = .default - var smartInsertDeleteType: UITextSmartInsertDeleteType = .default - var spellCheckingType: UITextSpellCheckingType = .default - var keyboardType: UIKeyboardType = .default - var keyboardAppearance: UIKeyboardAppearance = .default - var returnKeyType: UIReturnKeyType = .default - @objc var insertionPointColor: UIColor = .label { - didSet { - if insertionPointColor != oldValue { - updateCaretColor() - } - } - } - @objc var selectionBarColor: UIColor = .label { - didSet { - if selectionBarColor != oldValue { - updateCaretColor() - } - } - } - @objc var selectionHighlightColor: UIColor = .label.withAlphaComponent(0.2) { - didSet { - if selectionHighlightColor != oldValue { - updateCaretColor() - } - } - } - var isEditing = false { - didSet { - if isEditing != oldValue { - layoutManager.isEditing = isEditing - } - } - } - override var undoManager: UndoManager? { - return timedUndoManager - } - - // MARK: - Appearance - var theme: Theme { - didSet { - applyThemeToChildren() - } - } - var showLineNumbers = false { - didSet { - if showLineNumbers != oldValue { - caretRectService.showLineNumbers = showLineNumbers - gutterWidthService.showLineNumbers = showLineNumbers - layoutManager.showLineNumbers = showLineNumbers - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var lineSelectionDisplayType: LineSelectionDisplayType { - get { - return layoutManager.lineSelectionDisplayType - } - set { - layoutManager.lineSelectionDisplayType = newValue - } - } - var showTabs: Bool { - get { - return invisibleCharacterConfiguration.showTabs - } - set { - if newValue != invisibleCharacterConfiguration.showTabs { - invisibleCharacterConfiguration.showTabs = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var showSpaces: Bool { - get { - return invisibleCharacterConfiguration.showSpaces - } - set { - if newValue != invisibleCharacterConfiguration.showSpaces { - invisibleCharacterConfiguration.showSpaces = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var showNonBreakingSpaces: Bool { - get { - return invisibleCharacterConfiguration.showNonBreakingSpaces - } - set { - if newValue != invisibleCharacterConfiguration.showNonBreakingSpaces { - invisibleCharacterConfiguration.showNonBreakingSpaces = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var showLineBreaks: Bool { - get { - return invisibleCharacterConfiguration.showLineBreaks - } - set { - if newValue != invisibleCharacterConfiguration.showLineBreaks { - invisibleCharacterConfiguration.showLineBreaks = newValue - invalidateLines() - layoutManager.setNeedsLayout() - layoutManager.setNeedsDisplayOnLines() - setNeedsLayout() - } - } - } - var showSoftLineBreaks: Bool { - get { - return invisibleCharacterConfiguration.showSoftLineBreaks - } - set { - if newValue != invisibleCharacterConfiguration.showSoftLineBreaks { - invisibleCharacterConfiguration.showSoftLineBreaks = newValue - invalidateLines() - layoutManager.setNeedsLayout() - layoutManager.setNeedsDisplayOnLines() - setNeedsLayout() - } - } - } - var tabSymbol: String { - get { - return invisibleCharacterConfiguration.tabSymbol - } - set { - if newValue != invisibleCharacterConfiguration.tabSymbol { - invisibleCharacterConfiguration.tabSymbol = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var spaceSymbol: String { - get { - return invisibleCharacterConfiguration.spaceSymbol - } - set { - if newValue != invisibleCharacterConfiguration.spaceSymbol { - invisibleCharacterConfiguration.spaceSymbol = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var nonBreakingSpaceSymbol: String { - get { - return invisibleCharacterConfiguration.nonBreakingSpaceSymbol - } - set { - if newValue != invisibleCharacterConfiguration.nonBreakingSpaceSymbol { - invisibleCharacterConfiguration.nonBreakingSpaceSymbol = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var lineBreakSymbol: String { - get { - return invisibleCharacterConfiguration.lineBreakSymbol - } - set { - if newValue != invisibleCharacterConfiguration.lineBreakSymbol { - invisibleCharacterConfiguration.lineBreakSymbol = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var softLineBreakSymbol: String { - get { - return invisibleCharacterConfiguration.softLineBreakSymbol - } - set { - if newValue != invisibleCharacterConfiguration.softLineBreakSymbol { - invisibleCharacterConfiguration.softLineBreakSymbol = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var indentStrategy: IndentStrategy = .tab(length: 2) { - didSet { - if indentStrategy != oldValue { - indentController.indentStrategy = indentStrategy - layoutManager.setNeedsLayout() - setNeedsLayout() - layoutIfNeeded() - } - } - } - var gutterLeadingPadding: CGFloat = 3 { - didSet { - if gutterLeadingPadding != oldValue { - gutterWidthService.gutterLeadingPadding = gutterLeadingPadding - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var gutterTrailingPadding: CGFloat = 3 { - didSet { - if gutterTrailingPadding != oldValue { - gutterWidthService.gutterTrailingPadding = gutterTrailingPadding - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var gutterMinimumCharacterCount: Int = 1 { - didSet { - if gutterMinimumCharacterCount != oldValue { - gutterWidthService.gutterMinimumCharacterCount = gutterMinimumCharacterCount - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var textContainerInset: UIEdgeInsets { - get { - return layoutManager.textContainerInset - } - set { - if newValue != layoutManager.textContainerInset { - caretRectService.textContainerInset = newValue - selectionRectService.textContainerInset = newValue - contentSizeService.textContainerInset = newValue - layoutManager.textContainerInset = newValue - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var isLineWrappingEnabled: Bool { - get { - return layoutManager.isLineWrappingEnabled - } - set { - if newValue != layoutManager.isLineWrappingEnabled { - contentSizeService.isLineWrappingEnabled = newValue - layoutManager.isLineWrappingEnabled = newValue - invalidateLines() - layoutManager.setNeedsLayout() - layoutManager.layoutIfNeeded() - } - } - } - var lineBreakMode: LineBreakMode = .byWordWrapping { - didSet { - if lineBreakMode != oldValue { - invalidateLines() - contentSizeService.invalidateContentSize() - layoutManager.setNeedsLayout() - layoutManager.layoutIfNeeded() - } - } - } - var gutterWidth: CGFloat { - return gutterWidthService.gutterWidth - } - var lineHeightMultiplier: CGFloat = 1 { - didSet { - if lineHeightMultiplier != oldValue { - selectionRectService.lineHeightMultiplier = lineHeightMultiplier - layoutManager.lineHeightMultiplier = lineHeightMultiplier - invalidateLines() - lineManager.estimatedLineHeight = estimatedLineHeight - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var kern: CGFloat = 0 { - didSet { - if kern != oldValue { - invalidateLines() - pageGuideController.kern = kern - contentSizeService.invalidateContentSize() - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var characterPairs: [CharacterPair] = [] { - didSet { - maximumLeadingCharacterPairComponentLength = characterPairs.map(\.leading.utf16.count).max() ?? 0 - } - } - var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode = .disabled - var showPageGuide = false { - didSet { - if showPageGuide != oldValue { - if showPageGuide { - addSubview(pageGuideController.guideView) - sendSubviewToBack(pageGuideController.guideView) - setNeedsLayout() - } else { - pageGuideController.guideView.removeFromSuperview() - setNeedsLayout() - } - } - } - } - var pageGuideColumn: Int { - get { - return pageGuideController.column - } - set { - if newValue != pageGuideController.column { - pageGuideController.column = newValue - setNeedsLayout() - } - } - } - private var estimatedLineHeight: CGFloat { - return theme.font.totalLineHeight * lineHeightMultiplier - } - var highlightedRanges: [HighlightedRange] { - get { - return highlightService.highlightedRanges - } - set { - if newValue != highlightService.highlightedRanges { - highlightService.highlightedRanges = newValue - layoutManager.setNeedsLayout() - layoutManager.layoutIfNeeded() - } - } - } - - // MARK: - Contents - weak var delegate: TextInputViewDelegate? - var string: NSString { - get { - return stringView.string - } - set { - if newValue != stringView.string { - stringView.string = newValue - languageMode.parse(newValue) - lineManager.rebuild() - if let oldSelectedRange = selectedRange { - inputDelegate?.selectionWillChange(self) - selectedRange = safeSelectionRange(from: oldSelectedRange) - inputDelegate?.selectionDidChange(self) - } - contentSizeService.invalidateContentSize() - gutterWidthService.invalidateLineNumberWidth() - invalidateLines() - layoutManager.setNeedsLayout() - layoutManager.layoutIfNeeded() - if !preserveUndoStackWhenSettingString { - undoManager?.removeAllActions() - } - } - } - } - var viewport: CGRect { - get { - return layoutManager.viewport - } - set { - if newValue != layoutManager.viewport { - layoutManager.viewport = newValue - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var scrollViewWidth: CGFloat = 0 { - didSet { - if scrollViewWidth != oldValue { - contentSizeService.scrollViewWidth = scrollViewWidth - layoutManager.scrollViewWidth = scrollViewWidth - if isLineWrappingEnabled { - invalidateLines() - } - } - } - } - var contentSize: CGSize { - return contentSizeService.contentSize - } - var selectedRange: NSRange? { - get { - return _selectedRange - } - set { - if newValue != _selectedRange { - _selectedRange = newValue - delegate?.textInputViewDidChangeSelection(self) - } - } - } - private var _selectedRange: NSRange? { - didSet { - if _selectedRange != oldValue { - layoutManager.selectedRange = _selectedRange - layoutManager.setNeedsLayoutLineSelection() - setNeedsLayout() - } - } - } - override var canBecomeFirstResponder: Bool { - return true - } -// weak var gutterParentView: UIView? { -// get { -// return layoutManager.gutterParentView -// } -// set { -// layoutManager.gutterParentView = newValue -// } -// } - var scrollViewSafeAreaInsets: UIEdgeInsets = .zero { - didSet { - if scrollViewSafeAreaInsets != oldValue { - layoutManager.safeAreaInsets = scrollViewSafeAreaInsets - } - } - } - var gutterContainerView: UIView { - return layoutManager.gutterContainerView - } - private(set) var stringView = StringView() { - didSet { - if stringView !== oldValue { - caretRectService.stringView = stringView - lineManager.stringView = stringView - lineControllerFactory.stringView = stringView - lineControllerStorage.stringView = stringView - layoutManager.stringView = stringView - indentController.stringView = stringView - lineMovementController.stringView = stringView - customTokenizer.stringView = stringView - } - } - } - private(set) var lineManager: LineManager { - didSet { - if lineManager !== oldValue { - indentController.lineManager = lineManager - lineMovementController.lineManager = lineManager - gutterWidthService.lineManager = lineManager - contentSizeService.lineManager = lineManager - caretRectService.lineManager = lineManager - selectionRectService.lineManager = lineManager - highlightService.lineManager = lineManager - customTokenizer.lineManager = lineManager - } - } - } - var viewHierarchyContainsCaret: Bool { - return textSelectionView?.subviews.count == 1 - } - var lineEndings: LineEnding = .lf - private(set) var isRestoringPreviouslyDeletedText = false - - // MARK: - Private - private var languageMode: InternalLanguageMode = PlainTextInternalLanguageMode() { - didSet { - if languageMode !== oldValue { - indentController.languageMode = languageMode - if let treeSitterLanguageMode = languageMode as? TreeSitterInternalLanguageMode { - treeSitterLanguageMode.delegate = self - } - } - } - } - private let lineControllerFactory: LineControllerFactory - private let lineControllerStorage: LineControllerStorage - private let layoutManager: LayoutManager - private let timedUndoManager = TimedUndoManager() - private let indentController: IndentController - private let lineMovementController: LineMovementController - private let pageGuideController = PageGuideController() - private let gutterWidthService: GutterWidthService - private let contentSizeService: ContentSizeService - private let caretRectService: CaretRectService - private let selectionRectService: SelectionRectService - private let highlightService: HighlightService - private let invisibleCharacterConfiguration = InvisibleCharacterConfiguration() - private var markedRange: NSRange? { - get { - return layoutManager.markedRange - } - set { - layoutManager.markedRange = newValue - } - } - private var floatingCaretView: FloatingCaretView? - private var insertionPointColorBeforeFloatingBegan: UIColor = .label - private var maximumLeadingCharacterPairComponentLength = 0 - private var textSelectionView: UIView? { - if let klass = NSClassFromString("UITextSelectionView") { - return subviews.first { $0.isKind(of: klass) } - } else { - return nil - } - } - private var hasPendingFullLayout = false - private let editMenuController = EditMenuController() - private var notifyInputDelegateAboutSelectionChangeInLayoutSubviews = false - private var notifyDelegateAboutSelectionChangeInLayoutSubviews = false - private var didCallPositionFromPositionInDirectionWithOffset = false - private var hasDeletedTextWithPendingLayoutSubviews = false - private var preserveUndoStackWhenSettingString = false - private var cancellables: [AnyCancellable] = [] - - // MARK: - Lifecycle - init(theme: Theme) { - self.theme = theme - lineManager = LineManager(stringView: stringView) - highlightService = HighlightService(lineManager: lineManager) - lineControllerFactory = LineControllerFactory(stringView: stringView, - highlightService: highlightService, - invisibleCharacterConfiguration: invisibleCharacterConfiguration) - lineControllerStorage = LineControllerStorage(stringView: stringView, lineControllerFactory: lineControllerFactory) - gutterWidthService = GutterWidthService(lineManager: lineManager) - contentSizeService = ContentSizeService(lineManager: lineManager, - lineControllerStorage: lineControllerStorage, - gutterWidthService: gutterWidthService, - invisibleCharacterConfiguration: invisibleCharacterConfiguration) - caretRectService = CaretRectService(stringView: stringView, - lineManager: lineManager, - lineControllerStorage: lineControllerStorage, - gutterWidthService: gutterWidthService) - selectionRectService = SelectionRectService(lineManager: lineManager, - contentSizeService: contentSizeService, - gutterWidthService: gutterWidthService, - caretRectService: caretRectService) - layoutManager = LayoutManager(lineManager: lineManager, - languageMode: languageMode, - stringView: stringView, - lineControllerStorage: lineControllerStorage, - contentSizeService: contentSizeService, - gutterWidthService: gutterWidthService, - caretRectService: caretRectService, - selectionRectService: selectionRectService, - highlightService: highlightService, - invisibleCharacterConfiguration: invisibleCharacterConfiguration) - 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) - applyThemeToChildren() - indentController.delegate = self - lineControllerStorage.delegate = self - gutterWidthService.gutterLeadingPadding = gutterLeadingPadding - gutterWidthService.gutterTrailingPadding = gutterTrailingPadding - layoutManager.delegate = self -// layoutManager.textInputView = self -// editMenuController.delegate = self -// editMenuController.setupEditMenu(in: self) - setupContentSizeObserver() - setupGutterWidthObserver() - } - - override func becomeFirstResponder() -> Bool { - if canBecomeFirstResponder { - delegate?.textInputViewWillBeginEditing(self) - } - let didBecomeFirstResponder = super.becomeFirstResponder() - if didBecomeFirstResponder { - delegate?.textInputViewDidBeginEditing(self) - } else { - // This is called in the case where: - // 1. The view is the first responder. - // 2. A view is presented modally on top of the editor. - // 3. The modally presented view is dismissed. - // 4. The responder chain attempts to make the text view first responder again but super.becomeFirstResponder() returns false. - delegate?.textInputViewDidCancelBeginEditing(self) - } - return didBecomeFirstResponder - } - - override func resignFirstResponder() -> Bool { - let didResignFirstResponder = super.resignFirstResponder() - if didResignFirstResponder { - delegate?.textInputViewDidEndEditing(self) - } - return didResignFirstResponder - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - hasDeletedTextWithPendingLayoutSubviews = false - layoutManager.layoutIfNeeded() - layoutManager.layoutLineSelectionIfNeeded() - layoutPageGuideIfNeeded() - // 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 notifyInputDelegateAboutSelectionChangeInLayoutSubviews { - inputDelegate?.selectionWillChange(self) - inputDelegate?.selectionDidChange(self) - } - if notifyDelegateAboutSelectionChangeInLayoutSubviews { - notifyDelegateAboutSelectionChangeInLayoutSubviews = false - delegate?.textInputViewDidChangeSelection(self) - } - } - - override func copy(_ sender: Any?) { - if let selectedTextRange = selectedTextRange, let text = text(in: selectedTextRange) { - UIPasteboard.general.string = text - } - } - - override func paste(_ sender: Any?) { - if let selectedTextRange = selectedTextRange, let string = UIPasteboard.general.string { - inputDelegate?.selectionWillChange(self) - let preparedText = prepareTextForInsertion(string) - replace(selectedTextRange, withText: preparedText) - inputDelegate?.selectionDidChange(self) - } - } - - override func cut(_ sender: Any?) { - if let selectedTextRange = selectedTextRange, let text = text(in: selectedTextRange) { - UIPasteboard.general.string = text - replace(selectedTextRange, withText: "") - } - } - - override func selectAll(_ sender: Any?) { - notifyInputDelegateAboutSelectionChangeInLayoutSubviews = true - selectedRange = NSRange(location: 0, length: string.length) - } - - /// When autocorrection is enabled and the user tap on a misspelled word, UITextInteraction will present - /// a UIMenuController with suggestions for the correct spelling of the word. Selecting a suggestion will - /// cause UITextInteraction to call the non-existing -replace(_:) function and pass an instance of the private - /// UITextReplacement type as parameter. We can't make autocorrection work properly without using private API. - @objc func replace(_ obj: NSObject) { - if let replacementText = obj.value(forKey: "_repl" + "Ttnemeca".reversed() + "ext") as? String { - if let indexedRange = obj.value(forKey: "_r" + "gna".reversed() + "e") as? IndexedRange { - replace(indexedRange, withText: replacementText) - } - } - } - - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - if action == #selector(copy(_:)) { - if let selectedTextRange = selectedTextRange { - return !selectedTextRange.isEmpty - } else { - return false - } - } else if action == #selector(cut(_:)) { - if let selectedTextRange = selectedTextRange { - return isEditing && !selectedTextRange.isEmpty - } else { - return false - } - } else if action == #selector(paste(_:)) { - return isEditing && UIPasteboard.general.hasStrings - } else if action == #selector(selectAll(_:)) { - return true - } else if action == #selector(replace(_:)) { - return true - } else if action == NSSelectorFromString("replaceTextInSelectedHighlightedRange") { - if let selectedRange = selectedRange, let highlightedRange = highlightedRange(for: selectedRange) { - return delegate?.textInputView(self, canReplaceTextIn: highlightedRange) ?? false - } else { - return false - } - } else { - return super.canPerformAction(action, withSender: sender) - } - } - - func linePosition(at location: Int) -> LinePosition? { - return lineManager.linePosition(at: location) - } - - func setState(_ state: TextViewState, addUndoAction: Bool = false) { - let oldText = stringView.string - let newText = state.stringView.string - stringView = state.stringView - theme = state.theme - languageMode = state.languageMode - lineControllerStorage.removeAllLineControllers() - lineManager = state.lineManager - lineManager.estimatedLineHeight = estimatedLineHeight - layoutManager.languageMode = state.languageMode - layoutManager.lineManager = state.lineManager - contentSizeService.invalidateContentSize() - gutterWidthService.invalidateLineNumberWidth() - if addUndoAction { - if newText != oldText { - let newRange = NSRange(location: 0, length: newText.length) - timedUndoManager.endUndoGrouping() - timedUndoManager.beginUndoGrouping() - addUndoOperation(replacing: newRange, withText: oldText as String) - timedUndoManager.endUndoGrouping() - } - } else { - timedUndoManager.removeAllActions() - } - if let oldSelectedRange = selectedRange { - inputDelegate?.selectionWillChange(self) - selectedRange = safeSelectionRange(from: oldSelectedRange) - inputDelegate?.selectionDidChange(self) - } - if window != nil { - performFullLayout() - } else { - hasPendingFullLayout = true - } - } - - func clearSelection() { - selectedRange = nil - } - -// func moveCaret(to point: CGPoint) { -// if let index = layoutManager.closestIndex(to: point) { -// selectedRange = NSRange(location: index, length: 0) -// } -// } - -// func setLanguageMode(_ languageMode: LanguageMode, completion: ((Bool) -> Void)? = nil) { -// let internalLanguageMode = InternalLanguageModeFactory.internalLanguageMode( -// from: languageMode, -// stringView: stringView, -// lineManager: lineManager) -// self.languageMode = internalLanguageMode -// layoutManager.languageMode = internalLanguageMode -// internalLanguageMode.parse(string) { [weak self] finished in -// if let self = self, finished { -// self.invalidateLines() -// self.layoutManager.setNeedsLayout() -// self.layoutManager.layoutIfNeeded() -// } -// completion?(finished) -// } -// } - -// func syntaxNode(at location: Int) -> SyntaxNode? { -// if let linePosition = lineManager.linePosition(at: location) { -// return languageMode.syntaxNode(at: linePosition) -// } else { -// return nil -// } -// } - -// func isIndentation(at location: Int) -> Bool { -// guard let line = lineManager.line(containingCharacterAt: location) else { -// return false -// } -// let localLocation = location - line.location -// guard localLocation >= 0 else { -// return false -// } -// let indentLevel = languageMode.currentIndentLevel(of: line, using: indentStrategy) -// let indentString = indentStrategy.string(indentLevel: indentLevel) -// return localLocation <= indentString.utf16.count -// } - -// func detectIndentStrategy() -> DetectedIndentStrategy { -// return languageMode.detectIndentStrategy() -// } - - func textPreview(containing range: NSRange) -> TextPreview? { - return layoutManager.textPreview(containing: range) - } - - func layoutLines(toLocation location: Int) { - layoutManager.layoutLines(toLocation: location) - } - - func redisplayVisibleLines() { - layoutManager.redisplayVisibleLines() - } - - override func didMoveToWindow() { - super.didMoveToWindow() - if hasPendingFullLayout && window != nil { - hasPendingFullLayout = false - performFullLayout() - } - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - // We end our current undo group when the user touches the view. - let result = super.hitTest(point, with: event) - if result === self { - timedUndoManager.endUndoGrouping() - } - return result - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { - invalidateLines() - layoutManager.setNeedsLayout() - } - } - - override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { - super.pressesEnded(presses, with: event) - if let keyCode = presses.first?.key?.keyCode, presses.count == 1 { - if markedRange != nil { - handleKeyPressDuringMultistageTextInput(keyCode: keyCode) - } - } - } -} - -// MARK: - Theming -private extension TextInputView { - private func applyThemeToChildren() { - gutterWidthService.font = theme.lineNumberFont - lineManager.estimatedLineHeight = estimatedLineHeight - indentController.indentFont = theme.font - pageGuideController.font = theme.font - pageGuideController.guideView.hairlineWidth = theme.pageGuideHairlineWidth - pageGuideController.guideView.hairlineColor = theme.pageGuideHairlineColor - pageGuideController.guideView.backgroundColor = theme.pageGuideBackgroundColor - layoutManager.theme = theme - } -} - -// MARK: - Navigation -private extension TextInputView { - 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 - // on macOS and I think that works quite well for plain text editors on iOS too. - guard let markedRange = markedRange, let markedText = stringView.substring(in: markedRange) else { - return - } - // We only unmark the text if the marked text contains specific characters only. - // Some languages use multistage text input extensively and for those iOS presents a UI when - // navigating with the arrow keys. We do not want to interfere with that interaction. - let characterSet = CharacterSet(charactersIn: "`´^¨") - guard markedText.rangeOfCharacter(from: characterSet.inverted) == nil else { - return - } - switch keyCode { - case .keyboardUpArrow: - navigate(in: .up, offset: 1) - unmarkText() - case .keyboardRightArrow: - navigate(in: .right, offset: 1) - unmarkText() - case .keyboardDownArrow: - navigate(in: .down, offset: 1) - unmarkText() - case .keyboardLeftArrow: - navigate(in: .left, offset: 1) - unmarkText() - case .keyboardEscape: - unmarkText() - default: - break - } - } - - private func navigate(in direction: UITextLayoutDirection, offset: Int) { - if let selectedRange = selectedRange { - if let location = lineMovementController.location(from: selectedRange.location, in: direction, offset: offset) { - self.selectedRange = NSRange(location: location, length: 0) - } - } - } -} - -// MARK: - Layout -private extension TextInputView { - private func layoutPageGuideIfNeeded() { - if showPageGuide { - // The width extension is used to make the page guide look "attached" to the right hand side, even when the scroll view bouncing on the right side. - let maxContentOffsetX = contentSizeService.contentWidth - viewport.width - let widthExtension = max(ceil(viewport.minX - maxContentOffsetX), 0) - let xPosition = gutterWidthService.gutterWidth + textContainerInset.left + pageGuideController.columnOffset - let width = max(bounds.width - xPosition + widthExtension, 0) - let orrigin = CGPoint(x: xPosition, y: viewport.minY) - let pageGuideSize = CGSize(width: width, height: viewport.height) - pageGuideController.guideView.frame = CGRect(origin: orrigin, size: pageGuideSize) - } - } - - private func performFullLayout() { - 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() - } - } - - private func setupContentSizeObserver() { - contentSizeService.$isContentSizeInvalid.filter { $0 }.sink { [weak self] _ in - if let self = self { - self.delegate?.textInputViewDidInvalidateContentSize(self) - } - }.store(in: &cancellables) - } - - private func setupGutterWidthObserver() { - gutterWidthService.didUpdateGutterWidth.sink { [weak self] in - if let self = self { - // Typeset lines again when the line number width changes since changing line number width may increase or reduce the number of line fragments in a line. - self.setNeedsLayout() - self.invalidateLines() - self.layoutManager.setNeedsLayout() - self.delegate?.textInputViewDidChangeGutterWidth(self) - } - }.store(in: &cancellables) - } -} - -// MARK: - Floating Caret -extension TextInputView { - func beginFloatingCursor(at point: CGPoint) { - if floatingCaretView == nil, let position = closestPosition(to: point) { - insertionPointColorBeforeFloatingBegan = insertionPointColor - insertionPointColor = insertionPointColorBeforeFloatingBegan.withAlphaComponent(0.5) - updateCaretColor() - let caretRect = self.caretRect(for: position) - let caretOrigin = CGPoint(x: point.x - caretRect.width / 2, y: point.y - caretRect.height / 2) - let floatingCaretView = FloatingCaretView() - floatingCaretView.backgroundColor = insertionPointColorBeforeFloatingBegan - floatingCaretView.frame = CGRect(origin: caretOrigin, size: caretRect.size) - addSubview(floatingCaretView) - self.floatingCaretView = floatingCaretView - delegate?.textInputViewDidBeginFloatingCursor(self) - } - } - - func updateFloatingCursor(at point: CGPoint) { - if let floatingCaretView = floatingCaretView { - let caretSize = floatingCaretView.frame.size - let caretOrigin = CGPoint(x: point.x - caretSize.width / 2, y: point.y - caretSize.height / 2) - floatingCaretView.frame = CGRect(origin: caretOrigin, size: caretSize) - } - } - - func endFloatingCursor() { - insertionPointColor = insertionPointColorBeforeFloatingBegan - updateCaretColor() - floatingCaretView?.removeFromSuperview() - floatingCaretView = nil - delegate?.textInputViewDidEndFloatingCursor(self) - } - - private func updateCaretColor() { - // Removing the UITextSelectionView and re-adding it forces it to query the insertion point color. - if let textSelectionView = textSelectionView { - textSelectionView.removeFromSuperview() - addSubview(textSelectionView) - } - } -} - -// MARK: - Rects -extension TextInputView { - func caretRect(for position: UITextPosition) -> CGRect { - guard let indexedPosition = position as? IndexedPosition else { - fatalError("Expected position to be of type \(IndexedPosition.self)") - } - return caretRectService.caretRect(at: indexedPosition.index, allowMovingCaretToNextLineFragment: true) - } - - func caretRect(at location: Int) -> CGRect { - return caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: true) - } - - func firstRect(for range: UITextRange) -> CGRect { - guard let indexedRange = range as? IndexedRange else { - fatalError("Expected range to be of type \(IndexedRange.self)") - } - return layoutManager.firstRect(for: indexedRange.range) - } -} - -// MARK: - Editing -extension TextInputView { - func insertText(_ text: String) { - let preparedText = prepareTextForInsertion(text) - isRestoringPreviouslyDeletedText = hasDeletedTextWithPendingLayoutSubviews - hasDeletedTextWithPendingLayoutSubviews = false - defer { - isRestoringPreviouslyDeletedText = false - } - // If there is no marked range or selected range then we fallback to appending text to the end of our string. - let selectedRange = markedRange ?? selectedRange ?? NSRange(location: stringView.string.length, length: 0) - guard shouldChangeText(in: selectedRange, replacementText: preparedText) else { - isRestoringPreviouslyDeletedText = false - return - } - // If we're inserting text then we can't have a marked range. However, UITextInput doesn't always clear the marked range - // before calling -insertText(_:), so we do it manually. This issue can be tested by entering a backtick (`) in an empty - // document, then pressing any arrow key (up, right, down or left) followed by the return key. - // The backtick will remain marked unless we manually clear the marked range. - markedRange = nil - if LineEnding(symbol: text) != nil { - indentController.insertLineBreak(in: selectedRange, using: lineEndings) - layoutIfNeeded() - delegate?.textInputViewDidChangeSelection(self) - } else { - replaceText(in: selectedRange, with: preparedText) - layoutIfNeeded() - delegate?.textInputViewDidChangeSelection(self) - } - } - - func deleteBackward() { - guard let selectedRange = markedRange ?? selectedRange, selectedRange.length > 0 else { - return - } - let deleteRange = rangeForDeletingText(in: selectedRange) - // If we're deleting everything in the marked range then we clear the marked range. UITextInput doesn't do that for us. - // Can be tested by entering a backtick (`) in an empty document and deleting it. - if deleteRange == markedRange { - markedRange = nil - } - guard shouldChangeText(in: deleteRange, replacementText: "") else { - return - } - // Set a flag indicating that we have deleted text. This is reset in -layoutSubviews() but if this has not been reset before insertText() is called, then UIKit deleted characters prior to inserting combined characters. This happens when UIKit turns Korean characters into a single character. E.g. when typing ㅇ followed by ㅓ UIKit will perform the following operations: - // 1. Delete ㅇ. - // 2. Delete the character before ㅇ. I'm unsure why this is needed. - // 3. Insert the character that was previously before ㅇ. - // 4. Insert the ㅇ and ㅓ but combined into the single character delete ㅇ and then insert 어. - // We can detect this case in insertText() by checking if this variable is true. - hasDeletedTextWithPendingLayoutSubviews = true - // Disable notifying delegate in layout subviews to prevent sending the selected range with length > 0 when deleting text. This aligns with the behavior of UITextView and was introduced to resolve issue #158: https://github.com/simonbs/Runestone/issues/158 - notifyDelegateAboutSelectionChangeInLayoutSubviews = false - // Disable notifying input 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. - 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 - if deleteRange.length == 1 { - selectedRangeAfterUndo = NSRange(location: selectedRange.upperBound, length: 0) - } else { - selectedRangeAfterUndo = selectedRange - } - let isDeletingMultipleCharacters = selectedRange.length > 1 - if isDeletingMultipleCharacters { - timedUndoManager.endUndoGrouping() - timedUndoManager.beginUndoGrouping() - } - replaceText(in: deleteRange, with: "", selectedRangeAfterUndo: selectedRangeAfterUndo) - // Sending selection changed without calling the input delegate directly. This ensures that both inputting Korean letters and deleting entire words with Option+Backspace works properly. - sendSelectionChangedToTextSelectionView() - if isDeletingMultipleCharacters { - timedUndoManager.endUndoGrouping() - } - delegate?.textInputViewDidChangeSelection(self) - } - - func replace(_ range: UITextRange, withText text: String) { - let preparedText = prepareTextForInsertion(text) - if let indexedRange = range as? IndexedRange, shouldChangeText(in: indexedRange.range.nonNegativeLength, replacementText: preparedText) { - replaceText(in: indexedRange.range.nonNegativeLength, with: preparedText) - delegate?.textInputViewDidChangeSelection(self) - } - } - - func replaceText(in batchReplaceSet: BatchReplaceSet) { - guard !batchReplaceSet.replacements.isEmpty else { - return - } - var oldLinePosition: LinePosition? - if let oldSelectedRange = selectedRange { - oldLinePosition = lineManager.linePosition(at: oldSelectedRange.location) - } - let textEditHelper = TextEditHelper(stringView: stringView, lineManager: lineManager, lineEndings: lineEndings) - let newString = textEditHelper.string(byApplying: batchReplaceSet) - setStringWithUndoAction(newString) - if let oldLinePosition = oldLinePosition { - // By restoring the selected range using the old line position we can better preserve the old selected language. - moveCaret(to: oldLinePosition) - } - } - - func text(in range: UITextRange) -> String? { - if let indexedRange = range as? IndexedRange { - return text(in: indexedRange.range.nonNegativeLength) - } else { - return nil - } - } - - func text(in range: NSRange) -> String? { - return stringView.substring(in: range) - } - - private func setStringWithUndoAction(_ newString: NSString) { - guard newString != string else { - return - } - guard let oldString = stringView.string.copy() as? NSString else { - return - } - timedUndoManager.endUndoGrouping() - let oldSelectedRange = selectedRange - preserveUndoStackWhenSettingString = true - string = newString - preserveUndoStackWhenSettingString = false - timedUndoManager.beginUndoGrouping() - timedUndoManager.setActionName(L10n.Undo.ActionName.replaceAll) - timedUndoManager.registerUndo(withTarget: self) { textInputView in - textInputView.setStringWithUndoAction(oldString) - } - timedUndoManager.endUndoGrouping() - delegate?.textInputViewDidChange(self) - if let oldSelectedRange = oldSelectedRange { - selectedRange = safeSelectionRange(from: oldSelectedRange) - } - } - - private func rangeForDeletingText(in range: NSRange) -> NSRange { - var resultingRange = range - if range.length == 1, let indentRange = indentController.indentRangeInFrontOfLocation(range.upperBound) { - resultingRange = indentRange - } else { - resultingRange = string.customRangeOfComposedCharacterSequences(for: range) - } - // If deleting the leading component of a character pair we may also expand the range to delete the trailing component. - if characterPairTrailingComponentDeletionMode == .immediatelyFollowingLeadingComponent - && maximumLeadingCharacterPairComponentLength > 0 - && resultingRange.length <= maximumLeadingCharacterPairComponentLength { - let stringToDelete = stringView.substring(in: resultingRange) - if let characterPair = characterPairs.first(where: { $0.leading == stringToDelete }) { - let trailingComponentLength = characterPair.trailing.utf16.count - let trailingComponentRange = NSRange(location: resultingRange.upperBound, length: trailingComponentLength) - if stringView.substring(in: trailingComponentRange) == characterPair.trailing { - let deleteRange = trailingComponentRange.upperBound - resultingRange.lowerBound - resultingRange = NSRange(location: resultingRange.lowerBound, length: deleteRange) - } - } - } - return resultingRange - } - - private func replaceText(in range: NSRange, - with newString: String, - selectedRangeAfterUndo: NSRange? = nil, - undoActionName: String = L10n.Undo.ActionName.typing) { - let nsNewString = newString as NSString - let currentText = text(in: range) ?? "" - let newRange = NSRange(location: range.location, length: nsNewString.length) - addUndoOperation(replacing: newRange, withText: currentText, selectedRangeAfterUndo: selectedRangeAfterUndo, actionName: undoActionName) - _selectedRange = NSRange(location: newRange.upperBound, length: 0) - let textEditHelper = TextEditHelper(stringView: stringView, lineManager: lineManager, lineEndings: lineEndings) - let textEditResult = textEditHelper.replaceText(in: range, with: newString) - let textChange = textEditResult.textChange - let lineChangeSet = textEditResult.lineChangeSet - let languageModeLineChangeSet = languageMode.textDidChange(textChange) - lineChangeSet.union(with: languageModeLineChangeSet) - applyLineChangesToLayoutManager(lineChangeSet) - let updatedTextEditResult = TextEditResult(textChange: textChange, lineChangeSet: lineChangeSet) - delegate?.textInputViewDidChange(self) - if updatedTextEditResult.didAddOrRemoveLines { - delegate?.textInputViewDidInvalidateContentSize(self) - } - } - - private func applyLineChangesToLayoutManager(_ lineChangeSet: LineChangeSet) { - let didAddOrRemoveLines = !lineChangeSet.insertedLines.isEmpty || !lineChangeSet.removedLines.isEmpty - if didAddOrRemoveLines { - contentSizeService.invalidateContentSize() - for removedLine in lineChangeSet.removedLines { - lineControllerStorage.removeLineController(withID: removedLine.id) - contentSizeService.removeLine(withID: removedLine.id) - } - } - let editedLineIDs = Set(lineChangeSet.editedLines.map(\.id)) - layoutManager.redisplayLines(withIDs: editedLineIDs) - if didAddOrRemoveLines { - gutterWidthService.invalidateLineNumberWidth() - } - layoutManager.setNeedsLayout() - layoutManager.layoutIfNeeded() - } - - private func shouldChangeText(in range: NSRange, replacementText text: String) -> Bool { - return delegate?.textInputView(self, shouldChangeTextIn: range, replacementText: text) ?? true - } - - private func addUndoOperation(replacing range: NSRange, - withText text: String, - selectedRangeAfterUndo: NSRange? = nil, - actionName: String = L10n.Undo.ActionName.typing) { - let oldSelectedRange = selectedRangeAfterUndo ?? selectedRange - timedUndoManager.beginUndoGrouping() - timedUndoManager.setActionName(actionName) - timedUndoManager.registerUndo(withTarget: self) { textInputView in - textInputView.inputDelegate?.selectionWillChange(textInputView) - textInputView.replaceText(in: range, with: text) - textInputView.selectedRange = oldSelectedRange - textInputView.inputDelegate?.selectionDidChange(textInputView) - } - } - - private func prepareTextForInsertion(_ text: String) -> String { - // Ensure all line endings match our preferred line endings. - var preparedText = text - let lineEndingsToReplace: [LineEnding] = [.crlf, .cr, .lf].filter { $0 != lineEndings } - for lineEnding in lineEndingsToReplace { - preparedText = preparedText.replacingOccurrences(of: lineEnding.symbol, with: lineEndings.symbol) - } - return preparedText - } -} - -// MARK: - Selection -extension TextInputView { - func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { - if let indexedRange = range as? IndexedRange { - return selectionRectService.selectionRects(in: indexedRange.range.nonNegativeLength) - } else { - return [] - } - } - - private func safeSelectionRange(from range: NSRange) -> NSRange { - let stringLength = stringView.string.length - let cappedLocation = min(max(range.location, 0), stringLength) - let cappedLength = min(max(range.length, 0), stringLength - cappedLocation) - return NSRange(location: cappedLocation, length: cappedLength) - } - - private func moveCaret(to linePosition: LinePosition) { - if linePosition.row < lineManager.lineCount { - let line = lineManager.line(atRow: linePosition.row) - let location = line.location + min(linePosition.column, line.data.length) - selectedRange = NSRange(location: location, length: 0) - } else { - selectedRange = nil - } - } - - private func sendSelectionChangedToTextSelectionView() { - // The only way I've found to get the selection change to be reflected properly while still supporting Korean, Chinese, and deleting words with Option+Backspace is to call a private API in some cases. However, as pointed out by Alexander Blach in the following PR, there is another workaround to the issue. - // When passing nil to the input delete, the text selection is update but the text input ignores it. - // Even the Swift Playgrounds app does not get this right for all languages in all cases, so there seems to be some workarounds needed to due bugs in internal classes in UIKit that communicate with instances of UITextInput. - inputDelegate?.selectionDidChange(nil) - } -} - -// MARK: - Indent and Outdent -//extension TextInputView { -// func shiftLeft() { -// if let selectedRange = selectedRange { -// inputDelegate?.textWillChange(self) -// indentController.shiftLeft(in: selectedRange) -// inputDelegate?.textDidChange(self) -// } -// } -// -// func shiftRight() { -// if let selectedRange = selectedRange { -// inputDelegate?.textWillChange(self) -// indentController.shiftRight(in: selectedRange) -// inputDelegate?.textDidChange(self) -// } -// } -//} - -// MARK: - Move Lines -extension TextInputView { - func moveSelectedLinesUp() { - moveSelectedLine(byOffset: -1, undoActionName: L10n.Undo.ActionName.moveLinesUp) - } - - func moveSelectedLinesDown() { - moveSelectedLine(byOffset: 1, undoActionName: L10n.Undo.ActionName.moveLinesDown) - } - - private func moveSelectedLine(byOffset lineOffset: Int, undoActionName: String) { - guard let oldSelectedRange = selectedRange else { - return - } - let moveLinesService = MoveLinesService(stringView: stringView, lineManager: lineManager, lineEndingSymbol: lineEndings.symbol) - guard let operation = moveLinesService.operationForMovingLines(in: oldSelectedRange, byOffset: lineOffset) else { - return - } - timedUndoManager.endUndoGrouping() - timedUndoManager.beginUndoGrouping() - replaceText(in: operation.removeRange, with: "", undoActionName: undoActionName) - replaceText(in: operation.replacementRange, with: operation.replacementString, undoActionName: undoActionName) - notifyInputDelegateAboutSelectionChangeInLayoutSubviews = true - selectedRange = operation.selectedRange - timedUndoManager.endUndoGrouping() - } -} - -// MARK: - Marking -extension TextInputView { - func setMarkedText(_ markedText: String?, selectedRange: NSRange) { - guard let range = markedRange ?? self.selectedRange else { - return - } - let markedText = markedText ?? "" - guard shouldChangeText(in: range, replacementText: markedText) else { - return - } - 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. - let preferredSelectedRange = NSRange(location: range.location + selectedRange.location, length: selectedRange.length) - inputDelegate?.selectionWillChange(self) - _selectedRange = safeSelectionRange(from: preferredSelectedRange) - inputDelegate?.selectionDidChange(self) - delegate?.textInputViewDidUpdateMarkedRange(self) - } - - func unmarkText() { - inputDelegate?.selectionWillChange(self) - markedRange = nil - inputDelegate?.selectionDidChange(self) - delegate?.textInputViewDidUpdateMarkedRange(self) - } -} - -// MARK: - Ranges and Positions -extension TextInputView { - func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { - // This implementation seems to match the behavior of UITextView. - guard let indexedRange = range as? IndexedRange else { - return nil - } - switch direction { - case .left, .up: - return IndexedPosition(index: indexedRange.range.lowerBound) - case .right, .down: - return IndexedPosition(index: indexedRange.range.upperBound) - @unknown default: - return nil - } - } - - func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? { - guard let indexedPosition = position as? IndexedPosition else { - return nil - } - didCallPositionFromPositionInDirectionWithOffset = true - guard let newLocation = lineMovementController.location(from: indexedPosition.index, in: direction, offset: offset) else { - return nil - } - return IndexedPosition(index: newLocation) - } - - func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { - // This implementation seems to match the behavior of UITextView. - guard let indexedPosition = position as? IndexedPosition else { - return nil - } - switch direction { - case .left, .up: - let leftIndex = max(indexedPosition.index - 1, 0) - return IndexedRange(location: leftIndex, length: indexedPosition.index - leftIndex) - case .right, .down: - let rightIndex = min(indexedPosition.index + 1, stringView.string.length) - return IndexedRange(location: indexedPosition.index, length: rightIndex - indexedPosition.index) - @unknown default: - return nil - } - } - - func characterRange(at point: CGPoint) -> UITextRange? { - guard let index = layoutManager.closestIndex(to: point) else { - return nil - } - let cappedIndex = max(index - 1, 0) - let range = stringView.string.customRangeOfComposedCharacterSequence(at: cappedIndex) - return IndexedRange(range) - } - - func closestPosition(to point: CGPoint) -> UITextPosition? { - if let index = layoutManager.closestIndex(to: point) { - return IndexedPosition(index: index) - } else { - return nil - } - } - - func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { - guard let indexedRange = range as? IndexedRange else { - return nil - } - guard let index = layoutManager.closestIndex(to: point) else { - return nil - } - let minimumIndex = indexedRange.range.lowerBound - let maximumIndex = indexedRange.range.upperBound - let cappedIndex = min(max(index, minimumIndex), maximumIndex) - return IndexedPosition(index: cappedIndex) - } - - func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { - guard let fromIndexedPosition = fromPosition as? IndexedPosition, let toIndexedPosition = toPosition as? IndexedPosition else { - return nil - } - let range = NSRange(location: fromIndexedPosition.index, length: toIndexedPosition.index - fromIndexedPosition.index) - return IndexedRange(range) - } - - func position(from position: UITextPosition, offset: Int) -> UITextPosition? { - guard let indexedPosition = position as? IndexedPosition else { - return nil - } - let newPosition = indexedPosition.index + offset - guard newPosition >= 0 && newPosition <= string.length else { - return nil - } - return IndexedPosition(index: newPosition) - } - - func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { - guard let indexedPosition = position as? IndexedPosition, let otherIndexedPosition = other as? IndexedPosition else { - #if targetEnvironment(macCatalyst) - // Mac Catalyst may pass to `position`. I'm not sure what the right way to deal with that is but returning .orderedSame seems to work. - return .orderedSame - #else - fatalError("Positions must be of type \(IndexedPosition.self)") - #endif - } - if indexedPosition.index < otherIndexedPosition.index { - return .orderedAscending - } else if indexedPosition.index > otherIndexedPosition.index { - return .orderedDescending - } else { - return .orderedSame - } - } - - func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { - if let fromPosition = from as? IndexedPosition, let toPosition = toPosition as? IndexedPosition { - return toPosition.index - fromPosition.index - } else { - return 0 - } - } -} - -// MARK: - Writing Direction -extension TextInputView { - func baseWritingDirection(for position: UITextPosition, in direction: UITextStorageDirection) -> NSWritingDirection { - return .natural - } - - func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) {} -} - -// MARK: - UIEditMenuInteraction -extension TextInputView { - func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? { - return editMenuController.editMenu(for: textRange, suggestedActions: suggestedActions) - } - -// func presentEditMenuForText(in range: NSRange) { -// editMenuController.presentEditMenu(from: self, forTextIn: range) -// } - - @objc private func replaceTextInSelectedHighlightedRange() { - if let selectedRange = selectedRange, let highlightedRange = highlightedRange(for: selectedRange) { - delegate?.textInputView(self, replaceTextIn: highlightedRange) - } - } - - private func highlightedRange(for range: NSRange) -> HighlightedRange? { - return highlightedRanges.first { $0.range == range } - } -} - -// MARK: - TreeSitterLanguageModeDeleage -extension TextInputView: TreeSitterLanguageModeDelegate { - func treeSitterLanguageMode(_ languageMode: TreeSitterInternalLanguageMode, bytesAt byteIndex: ByteCount) -> TreeSitterTextProviderResult? { - guard byteIndex.value >= 0 && byteIndex < stringView.string.byteCount else { - return nil - } - let targetByteCount: ByteCount = 4 * 1_024 - let endByte = min(byteIndex + targetByteCount, stringView.string.byteCount) - let byteRange = ByteRange(from: byteIndex, to: endByte) - if let result = stringView.bytes(in: byteRange) { - return TreeSitterTextProviderResult(bytes: result.bytes, length: UInt32(result.length.value)) - } else { - return nil - } - } -} - -// 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 layoutManager(_ layoutManager: LayoutManager, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) { - delegate?.textInputView(self, didProposeContentOffsetAdjustment: contentOffsetAdjustment) - } -} - -// MARK: - IndentControllerDelegate -extension TextInputView: IndentControllerDelegate { - func indentController(_ controller: IndentController, shouldInsert text: String, in range: NSRange) { - replaceText(in: range, with: text) - } - - func indentController(_ controller: IndentController, shouldSelect range: NSRange) { - inputDelegate?.selectionWillChange(self) - selectedRange = range - inputDelegate?.selectionDidChange(self) - } - - func indentControllerDidUpdateTabWidth(_ controller: IndentController) { - invalidateLines() - } -} - -// MARK: - EditMenuControllerDelegate -extension TextInputView: EditMenuControllerDelegate { - func editMenuController(_ controller: EditMenuController, caretRectAt location: Int) -> CGRect { - return caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: false) - } - - func editMenuControllerShouldReplaceText(_ controller: EditMenuController) { - replaceTextInSelectedHighlightedRange() - } - - func editMenuController(_ controller: EditMenuController, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - return delegate?.textInputView(self, canReplaceTextIn: highlightedRange) ?? false - } - - func editMenuController(_ controller: EditMenuController, highlightedRangeFor range: NSRange) -> HighlightedRange? { - return highlightedRange(for: range) - } - - func selectedRange(for controller: EditMenuController) -> NSRange? { - return selectedRange - } -} -#endif From e1c793e2837b5324fd8c0faad3a0de2250f4f88d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 10:14:39 +0100 Subject: [PATCH 018/232] Adds missing edit functions --- .../TextViewController+Editing.swift | 75 +++++++++++++++++-- .../TextViewController.swift | 2 +- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift index f8da68398..bebe699ed 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift @@ -1,6 +1,11 @@ import Foundation extension TextViewController { + var rangeForInsertingText: NSRange { + // If there is no marked range or selected range then we fallback to appending text to the end of our string. + markedRange ?? selectedRange ?? NSRange(location: stringView.string.length, length: 0) + } + func text(in range: NSRange) -> String? { stringView.substring(in: range.nonNegativeLength) } @@ -24,15 +29,29 @@ extension TextViewController { lineChangeSet.union(with: languageModeLineChangeSet) applyLineChangesToLayoutManager(lineChangeSet) let updatedTextEditResult = TextEditResult(textChange: textChange, lineChangeSet: lineChangeSet) - if isAutomaticScrollEnabled, let newRange = selectedRange, newRange.length == 0 { - scrollLocationToVisible(newRange.location) - } - textView.editorDelegate?.textViewDidChange(textView) + textDidChange() if updatedTextEditResult.didAddOrRemoveLines { invalidateContentSizeIfNeeded() } } + func replaceText(in batchReplaceSet: BatchReplaceSet) { + guard !batchReplaceSet.replacements.isEmpty else { + return + } + var oldLinePosition: LinePosition? + if let oldSelectedRange = selectedRange { + oldLinePosition = lineManager.linePosition(at: oldSelectedRange.location) + } + let textEditHelper = TextEditHelper(stringView: stringView, lineManager: lineManager, lineEndings: lineEndings) + let newString = textEditHelper.string(byApplying: batchReplaceSet) + setStringWithUndoAction(newString) + if let oldLinePosition = oldLinePosition { + // By restoring the selected range using the old line position we can better preserve the old selected language. + moveCaret(to: oldLinePosition) + } + } + func rangeForDeletingText(in range: NSRange) -> NSRange { var resultingRange = range if range.length == 1, let indentRange = indentController.indentRangeInFrontOfLocation(range.upperBound) { @@ -72,7 +91,7 @@ extension TextViewController { // UIKit is inserting text to combine characters, for example to combine two Korean characters into one, and we do not want to interfere with that. return textView.editorDelegate?.textView(textView, shouldChangeTextIn: range, replacementText: text) ?? true } else if let characterPair = characterPairs.first(where: { $0.trailing == text }), - skipInsertingTrailingComponent(of: characterPair, in: range) { + skipInsertingTrailingComponent(of: characterPair, in: range) { return false } else if let characterPair = characterPairs.first(where: { $0.leading == text }), insertLeadingComponent(of: characterPair, in: range) { return false @@ -104,13 +123,12 @@ private extension TextViewController { return false } if selectedRange.length == 0 { - insertText(characterPair.leading + characterPair.trailing) + replaceText(in: selectedRange, with: characterPair.leading + characterPair.trailing) self.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: 0) return true } else if let text = text(in: selectedRange) { let modifiedText = characterPair.leading + text + characterPair.trailing - let indexedRange = IndexedRange(selectedRange) - replace(indexedRange, withText: modifiedText) + replaceText(in: selectedRange, with: modifiedText) self.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: range.length) return true } else { @@ -137,4 +155,45 @@ private extension TextViewController { } return true } + + private func setStringWithUndoAction(_ newString: NSString) { + guard newString != stringView.string else { + return + } + guard let oldString = stringView.string.copy() as? NSString else { + return + } + timedUndoManager.endUndoGrouping() + let oldSelectedRange = selectedRange + preserveUndoStackWhenSettingString = true + text = newString as String + preserveUndoStackWhenSettingString = false + timedUndoManager.beginUndoGrouping() + timedUndoManager.setActionName(L10n.Undo.ActionName.replaceAll) + timedUndoManager.registerUndo(withTarget: self) { textInputView in + textInputView.setStringWithUndoAction(oldString) + } + timedUndoManager.endUndoGrouping() + textDidChange() + if let oldSelectedRange = oldSelectedRange { + selectedRange = safeSelectionRange(from: oldSelectedRange) + } + } + + private func textDidChange() { + if isAutomaticScrollEnabled, let newRange = selectedRange, newRange.length == 0 { + scrollLocationToVisible(newRange.location) + } + textView.editorDelegate?.textViewDidChange(textView) + } + + private func moveCaret(to linePosition: LinePosition) { + if linePosition.row < lineManager.lineCount { + let line = lineManager.line(atRow: linePosition.row) + let location = line.location + min(linePosition.column, line.data.length) + selectedRange = NSRange(location: location, length: 0) + } else { + selectedRange = nil + } + } } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index d972bb4fc..39a0f008f 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -499,12 +499,12 @@ final class TextViewController { } var isAutomaticScrollEnabled = false var hasPendingFullLayout = false + var preserveUndoStackWhenSettingString = false private(set) var maximumLeadingCharacterPairComponentLength = 0 private var estimatedLineHeight: CGFloat { theme.font.totalLineHeight * lineHeightMultiplier } - private var preserveUndoStackWhenSettingString = false private var cancellables: Set = [] init(textView: TextView) { From 47a384317ca1c8fa702c262f2d20ab4536b6c3c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 10:25:29 +0100 Subject: [PATCH 019/232] Adds missing case --- .../RunestoneTomorrowNightTheme/TomorrowNightTheme.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/Themes/Sources/RunestoneTomorrowNightTheme/TomorrowNightTheme.swift b/Example/Themes/Sources/RunestoneTomorrowNightTheme/TomorrowNightTheme.swift index d2b959517..b1fd605f2 100644 --- a/Example/Themes/Sources/RunestoneTomorrowNightTheme/TomorrowNightTheme.swift +++ b/Example/Themes/Sources/RunestoneTomorrowNightTheme/TomorrowNightTheme.swift @@ -48,7 +48,7 @@ public final class TomorrowNightTheme: EditorTheme { return UIColor(namedInModule: "TomorrowNightOrange") case .keyword: return UIColor(namedInModule: "TomorrowNightPurple") - case .variableBuiltin: + case .variableBuiltin, .constantBuiltin: return UIColor(namedInModule: "TomorrowNightRed") } } From 8b7b6786420783c753f635e80b2a6b712aea32e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 10:41:26 +0100 Subject: [PATCH 020/232] Adds missing call to textViewController --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 4e015adfb..83b2ca626 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -785,7 +785,7 @@ open class TextView: UIScrollView { /// - Parameters: /// - batchReplaceSet: Set of ranges to replace with a text. public func replaceText(in batchReplaceSet: BatchReplaceSet) { - // textInputView.replaceText(in: batchReplaceSet) + textViewController.replaceText(in: batchReplaceSet) } /// Returns the syntax node at the specified location in the document. From ffc228ca908dfbc39f30a4e82a963c7e0a9d0104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 10:41:37 +0100 Subject: [PATCH 021/232] Makes lineEndings backed by equivalent in textViewController --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 83b2ca626..f81202208 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -461,7 +461,14 @@ open class TextView: UIScrollView { /// The value only affects new line breaks inserted in the text view and changing this value does not change the line endings of the text in the text view. Defaults to Unix (LF). /// /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)`` and ``TextView/replaceText(in:)``. - public var lineEndings: LineEnding = .lf + public var lineEndings: LineEnding { + get { + return textViewController.lineEndings + } + set { + textViewController.lineEndings = newValue + } + } /// 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 /// A boolean value that enables a text view’s built-in find interaction. From bf7d1165343c0609e797435b9e17e3ae21930951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 10:41:47 +0100 Subject: [PATCH 022/232] Adds text(in:) --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index f81202208..7f673906e 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -943,6 +943,13 @@ open class TextView: UIScrollView { textViewController.scrollRangeToVisible(range) } + /// Returns the text in the specified range. + /// - Parameter range: A range of text in the document. + /// - Returns: The substring that falls within the specified range. + public func text(in range: NSRange) -> String? { + textViewController.text(in: range) + } + /// Called when the iOS interface environment changes. open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) From ba2b1c0fd969f53603faf02370c3c7b024f74dcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 10:41:56 +0100 Subject: [PATCH 023/232] Adds replace(_:with:) --- .../Runestone/TextView/Core/iOS/TextView_iOS.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 7f673906e..06c2f9692 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -943,6 +943,17 @@ open class TextView: UIScrollView { textViewController.scrollRangeToVisible(range) } + /// Replaces the text that is in the specified range. + /// - Parameters: + /// - range: A range of text in the document. + /// - text: A string to replace the text in range. + public func replace(_ range: NSRange, withText text: String) { + inputDelegate?.selectionWillChange(self) + let indexedRange = IndexedRange(range) + replace(indexedRange, withText: text) + inputDelegate?.selectionDidChange(self) + } + /// Returns the text in the specified range. /// - Parameter range: A range of text in the document. /// - Returns: The substring that falls within the specified range. From 24e7f8b27de1a4a0ae515640db7c091342f193b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 12:04:24 +0100 Subject: [PATCH 024/232] Fixes UI tests --- UITests/Host/Sources/MainView.swift | 3 ++- UITests/HostUITests/ChineseInputTests.swift | 4 ++-- UITests/HostUITests/KoreanInputTests.swift | 18 +++++++++--------- .../HostUITests/XCUIApplication+Helpers.swift | 10 +--------- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/UITests/Host/Sources/MainView.swift b/UITests/Host/Sources/MainView.swift index 649539dc5..2a33a8e6c 100644 --- a/UITests/Host/Sources/MainView.swift +++ b/UITests/Host/Sources/MainView.swift @@ -6,6 +6,8 @@ final class MainView: UIView { let this = TextView() this.alwaysBounceVertical = true this.contentInsetAdjustmentBehavior = .always + this.translatesAutoresizingMaskIntoConstraints = false + this.accessibilityIdentifier = "RunestoneTextView" this.autocorrectionType = .no this.autocapitalizationType = .none this.smartDashesType = .no @@ -23,7 +25,6 @@ final class MainView: UIView { BasicCharacterPair(leading: "\"", trailing: "\""), BasicCharacterPair(leading: "'", trailing: "'") ] - this.translatesAutoresizingMaskIntoConstraints = false return this }() diff --git a/UITests/HostUITests/ChineseInputTests.swift b/UITests/HostUITests/ChineseInputTests.swift index cff954558..217cd6c0f 100644 --- a/UITests/HostUITests/ChineseInputTests.swift +++ b/UITests/HostUITests/ChineseInputTests.swift @@ -2,7 +2,7 @@ import XCTest final class ChineseInputTests: XCTestCase { func testEnteringMarkedText() throws { - let app = XCUIApplication().disablingTextPersistance() + let app = XCUIApplication() app.launch() app.textView?.tap() app.keys["日"].tap() @@ -12,7 +12,7 @@ final class ChineseInputTests: XCTestCase { } func testEnteringMarkedTextTwoTimes() throws { - let app = XCUIApplication().disablingTextPersistance() + let app = XCUIApplication() app.launch() app.textView?.tap() app.keys["日"].tap() diff --git a/UITests/HostUITests/KoreanInputTests.swift b/UITests/HostUITests/KoreanInputTests.swift index 792aafca8..0256a22da 100644 --- a/UITests/HostUITests/KoreanInputTests.swift +++ b/UITests/HostUITests/KoreanInputTests.swift @@ -2,7 +2,7 @@ import XCTest final class KoreanInputTests: XCTestCase { func testEnteringCombinedCharacter() throws { - let app = XCUIApplication().disablingTextPersistance() + let app = XCUIApplication() app.launch() app.textView?.tap() app.keys["ㅇ"].tap() @@ -12,7 +12,7 @@ final class KoreanInputTests: XCTestCase { } func testEnteringTwoCombinedCharacters() throws { - let app = XCUIApplication().disablingTextPersistance() + let app = XCUIApplication() app.launch() app.textView?.tap() app.keys["ㅇ"].tap() @@ -25,7 +25,7 @@ final class KoreanInputTests: XCTestCase { } func testEnteringThreeCombinedCharacters() throws { - let app = XCUIApplication().disablingTextPersistance() + let app = XCUIApplication() app.launch() app.textView?.tap() app.keys["ㅇ"].tap() @@ -41,7 +41,7 @@ final class KoreanInputTests: XCTestCase { } func testEnteringTwoCombinedCharactersSeparatedBySpace() throws { - let app = XCUIApplication().disablingTextPersistance() + let app = XCUIApplication() app.launch() app.textView?.tap() app.keys["ㅇ"].tap() @@ -55,7 +55,7 @@ final class KoreanInputTests: XCTestCase { } func testEnteringTwoCombinedCharactersSeparatedByTwoLineBreaks() throws { - let app = XCUIApplication().disablingTextPersistance() + let app = XCUIApplication() app.launch() app.textView?.tap() app.keys["ㅇ"].tap() @@ -71,7 +71,7 @@ final class KoreanInputTests: XCTestCase { func testEnteringTwoDifferentCombinedCharacters() throws { // Test case inspired by a bug report in the Textastic forums: // https://feedback.textasticapp.com/communities/1/topics/3570-korean-text-typing-error - let app = XCUIApplication().disablingTextPersistance() + let app = XCUIApplication() app.launch() app.textView?.tap() app.keys["ㄱ"].tap() @@ -84,7 +84,7 @@ final class KoreanInputTests: XCTestCase { } func testEnteringKoreanBetweenQuotationMarks() throws { - let app = XCUIApplication().disablingTextPersistance() + let app = XCUIApplication() app.launch() app.textView?.tap() app.keys["more"].tap() @@ -96,7 +96,7 @@ final class KoreanInputTests: XCTestCase { } func testInsertingKoreanCharactersBelowStringContainingKoreanLetters() throws { - let app = XCUIApplication().disablingTextPersistance() + let app = XCUIApplication() app.launch() app.textView?.tap() app.keys["ㅇ"].tap() @@ -127,7 +127,7 @@ final class KoreanInputTests: XCTestCase { } func testInsertingKoreanCharactersInTextWithCRLFLineEndings() throws { - let app = XCUIApplication().disablingTextPersistance().usingCRLFLineEndings() + let app = XCUIApplication().usingCRLFLineEndings() app.launch() app.textView?.tap() app.typeText("테스트") diff --git a/UITests/HostUITests/XCUIApplication+Helpers.swift b/UITests/HostUITests/XCUIApplication+Helpers.swift index fff066454..d3884114b 100644 --- a/UITests/HostUITests/XCUIApplication+Helpers.swift +++ b/UITests/HostUITests/XCUIApplication+Helpers.swift @@ -1,13 +1,12 @@ import XCTest private enum EnvironmentKey { - static let disableTextPersistance = "disableTextPersistance" static let crlfLineEndings = "crlfLineEndings" } extension XCUIApplication { var textView: XCUIElement? { - return scrollViews.children(matching: .textView).element + textViews["RunestoneTextView"] } func tap(at point: CGPoint) { @@ -17,13 +16,6 @@ extension XCUIApplication { coordinate.tap() } - func disablingTextPersistance() -> Self { - var newLaunchEnvironment = launchEnvironment - newLaunchEnvironment[EnvironmentKey.disableTextPersistance] = "1" - launchEnvironment = newLaunchEnvironment - return self - } - func usingCRLFLineEndings() -> Self { var newLaunchEnvironment = launchEnvironment newLaunchEnvironment[EnvironmentKey.crlfLineEndings] = "1" From 434145369740561649572b32e462c50c186be5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 12:07:33 +0100 Subject: [PATCH 025/232] Improves formatting --- .../RunestoneTests/TreeSitterParserTests.swift | 6 ++++-- .../iOS/TextInputStringTokenizerTests.swift | 18 +++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/Tests/RunestoneTests/TreeSitterParserTests.swift b/Tests/RunestoneTests/TreeSitterParserTests.swift index 6b952a346..70b65ad70 100644 --- a/Tests/RunestoneTests/TreeSitterParserTests.swift +++ b/Tests/RunestoneTests/TreeSitterParserTests.swift @@ -29,7 +29,8 @@ final class TreeSitterParserTests: XCTestCase { newEndByte: string.byteCount, startPoint: TreeSitterTextPoint(row: 0, column: 0), oldEndPoint: TreeSitterTextPoint(row: 0, column: 23), - newEndPoint: TreeSitterTextPoint(row: 0, column: 23)) + newEndPoint: TreeSitterTextPoint(row: 0, column: 23) + ) oldTree?.apply(inputEdit) delegate.string = string let newTree = parser.parse(oldTree: oldTree) @@ -66,7 +67,8 @@ final class TreeSitterParserTests: XCTestCase { newEndByte: 830, startPoint: TreeSitterTextPoint(row: 0, column: 0), oldEndPoint: TreeSitterTextPoint(row: 15, column: 0), - newEndPoint: TreeSitterTextPoint(row: 15, column: 0)) + newEndPoint: TreeSitterTextPoint(row: 15, column: 0) + ) oldTree?.apply(inputEdit) delegate.string = string let newTree = parser.parse(oldTree: oldTree) diff --git a/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift b/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift index 071f05cdf..e63490cd5 100644 --- a/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift +++ b/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift @@ -272,9 +272,11 @@ Donec laoreet, massa sed commodo tincidunt, dui neque ullamcorper sapien, laoree let lineManager = LineManager(stringView: stringView) lineManager.rebuild() let highlightService = HighlightService(lineManager: lineManager) - let lineControllerFactory = LineControllerFactory(stringView: stringView, - highlightService: highlightService, - invisibleCharacterConfiguration: invisibleCharacterConfiguration) + let lineControllerFactory = LineControllerFactory( + stringView: stringView, + highlightService: highlightService, + invisibleCharacterConfiguration: invisibleCharacterConfiguration + ) let lineControllerStorage = LineControllerStorage(stringView: stringView, lineControllerFactory: lineControllerFactory) lineControllerStorage.delegate = self for row in 0 ..< lineManager.lineCount { @@ -282,10 +284,12 @@ Donec laoreet, massa sed commodo tincidunt, dui neque ullamcorper sapien, laoree let lineController = lineControllerStorage.getOrCreateLineController(for: line) lineController.prepareToDisplayString(toLocation: line.data.totalLength, syntaxHighlightAsynchronously: false) } - return TextInputStringTokenizer(textInput: textInputView, - stringView: stringView, - lineManager: lineManager, - lineControllerStorage: lineControllerStorage) + return TextInputStringTokenizer( + textInput: textInputView, + stringView: stringView, + lineManager: lineManager, + lineControllerStorage: lineControllerStorage + ) } } From 78acd7b2387cae9ef66f6bcd337b1f5852894f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 12:14:30 +0100 Subject: [PATCH 026/232] Fixes unit test --- .../RunestoneTests/iOS/TextInputStringTokenizerTests.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift b/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift index e63490cd5..ce7dc6356 100644 --- a/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift +++ b/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift @@ -264,9 +264,7 @@ Donec laoreet, massa sed commodo tincidunt, dui neque ullamcorper sapien, laoree } private func makeTokenizer() -> UITextInputTokenizer { - let textInputView = TextInputView(theme: DefaultTheme()) - let stringLength = textInputView.stringView.string.length - textInputView.layoutLines(toLocation: stringLength) + let textView = TextView() let stringView = StringView(string: sampleText) let invisibleCharacterConfiguration = InvisibleCharacterConfiguration() let lineManager = LineManager(stringView: stringView) @@ -285,7 +283,7 @@ Donec laoreet, massa sed commodo tincidunt, dui neque ullamcorper sapien, laoree lineController.prepareToDisplayString(toLocation: line.data.totalLength, syntaxHighlightAsynchronously: false) } return TextInputStringTokenizer( - textInput: textInputView, + textInput: textView, stringView: stringView, lineManager: lineManager, lineControllerStorage: lineControllerStorage From 2a3de76e73eff88df8cb1a50036d81cb93b1167b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 12:36:15 +0100 Subject: [PATCH 027/232] Fixes caret placement when navigating to line --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 06c2f9692..d3800ca46 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -861,7 +861,8 @@ open class TextView: UIScrollView { /// - Returns: True if the text view could navigate to the specified line index, otherwise false. @discardableResult public func goToLine(_ lineIndex: Int, select selection: GoToLineSelection = .beginning) -> Bool { - textViewController.goToLine(lineIndex, select: selection) + notifyInputDelegateAboutSelectionChangeInLayoutSubviews = true + return textViewController.goToLine(lineIndex, select: selection) } /// Search for the specified query. From 6d87e25735da196b79e0094ac5cbae3648874498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 12:42:09 +0100 Subject: [PATCH 028/232] Fixes selection when navigating to highlights --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index d3800ca46..783ec6c79 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -915,18 +915,24 @@ open class TextView: UIScrollView { /// Selects a highlighted range behind the selected range if possible. public func selectPreviousHighlightedRange() { + inputDelegate?.selectionWillChange(self) textViewController.highlightNavigationController.selectPreviousRange() + inputDelegate?.selectionDidChange(self) } /// Selects a highlighted range after the selected range if possible. public func selectNextHighlightedRange() { + inputDelegate?.selectionWillChange(self) textViewController.highlightNavigationController.selectNextRange() + inputDelegate?.selectionDidChange(self) } /// Selects the highlighed range at the specified index. /// - Parameter index: Index of highlighted range to select. public func selectHighlightedRange(at index: Int) { + inputDelegate?.selectionWillChange(self) textViewController.highlightNavigationController.selectRange(at: index) + inputDelegate?.selectionDidChange(self) } /// Synchronously displays the visible lines. This can be used to immediately update the visible lines after setting the theme. Use with caution as redisplaying the visible lines can be a costly operation. From aa0161475d31545d905042975a9ae02e99f9b96c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 13:37:19 +0100 Subject: [PATCH 029/232] Removes unused CGContext --- .../LineController/LineFragmentRenderer.swift | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift index 984403204..6f0ca9bd9 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift @@ -34,7 +34,7 @@ final class LineFragmentRenderer { func draw(to context: CGContext, inCanvasOfSize canvasSize: CGSize) { drawHighlightedRanges(to: context, inCanvasOfSize: canvasSize) drawMarkedRange(to: context) - drawInvisibleCharacters(to: context) + drawInvisibleCharacters() drawText(to: context) } } @@ -87,11 +87,9 @@ private extension LineFragmentRenderer { } } - private func drawInvisibleCharacters(to context: CGContext) { - if showInvisibleCharacters { - if let string = delegate?.string(in: self) { - drawInvisibleCharacters(in: string, to: context) - } + private func drawInvisibleCharacters() { + if showInvisibleCharacters, let string = delegate?.string(in: self) { + drawInvisibleCharacters(in: string) } } @@ -106,7 +104,7 @@ private extension LineFragmentRenderer { context.restoreGState() } - private func drawInvisibleCharacters(in string: String, to context: CGContext) { + private func drawInvisibleCharacters(in string: String) { let textRange = CTLineGetStringRange(lineFragment.line) for (indexInLineFragment, substring) in string.enumerated() { let indexInLine = textRange.location + indexInLineFragment From ca8962896f02a1107d18cb3d3c9ae4b4badb9cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 14:07:01 +0100 Subject: [PATCH 030/232] Improves formatting --- .../TextView/Core/iOS/LineMovementController.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift b/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift index aaeee34f6..079d1bcb4 100644 --- a/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift +++ b/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift @@ -111,10 +111,12 @@ 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( From 146b7a8e8ec38dedae1efe5c2e4ccf68d2458f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 16:31:22 +0100 Subject: [PATCH 031/232] Adds app icon to Mac example --- .../AppIcon.appiconset/Contents.json | 10 ++++++++++ .../ExampleProjectMac_128x128.png | Bin 0 -> 11014 bytes .../ExampleProjectMac_128x128@2x.png | Bin 0 -> 27472 bytes .../ExampleProjectMac_16x16.png | Bin 0 -> 680 bytes .../ExampleProjectMac_16x16@2x.png | Bin 0 -> 1847 bytes .../ExampleProjectMac_256x256.png | Bin 0 -> 27472 bytes .../ExampleProjectMac_256x256@2x.png | Bin 0 -> 73119 bytes .../ExampleProjectMac_32x32.png | Bin 0 -> 1847 bytes .../ExampleProjectMac_32x32@2x.png | Bin 0 -> 4522 bytes .../ExampleProjectMac_512x512.png | Bin 0 -> 73119 bytes .../ExampleProjectMac_512x512@2x.png | Bin 0 -> 208821 bytes 11 files changed, 10 insertions(+) create mode 100644 Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_128x128.png create mode 100644 Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_128x128@2x.png create mode 100644 Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_16x16.png create mode 100644 Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_16x16@2x.png create mode 100644 Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_256x256.png create mode 100644 Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_256x256@2x.png create mode 100644 Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_32x32.png create mode 100644 Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_32x32@2x.png create mode 100644 Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_512x512.png create mode 100644 Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_512x512@2x.png diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/Contents.json index 3f00db43e..432c402c6 100644 --- a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,51 +1,61 @@ { "images" : [ { + "filename" : "ExampleProjectMac_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { + "filename" : "ExampleProjectMac_16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { + "filename" : "ExampleProjectMac_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { + "filename" : "ExampleProjectMac_32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { + "filename" : "ExampleProjectMac_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { + "filename" : "ExampleProjectMac_128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { + "filename" : "ExampleProjectMac_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { + "filename" : "ExampleProjectMac_256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { + "filename" : "ExampleProjectMac_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { + "filename" : "ExampleProjectMac_512x512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_128x128.png b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..f95b52f8d129615bb89e087c4ab014ec2d0c2a00 GIT binary patch literal 11014 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 CXb=$q literal 0 HcmV?d00001 diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_128x128@2x.png b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_128x128@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..fabfa6a5f0151ee470e2be1ad14eeb4938531572 GIT binary patch literal 27472 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 literal 0 HcmV?d00001 diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_16x16.png b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..67819353a6c23fd718df2c5cd309455c81a0850a GIT binary patch literal 680 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ea{HEjtmSN`?>!lvI6-QsS%!O zzP=1vKsE;hV|yk83y{SK#8N=az`(SC2`(bAfEmFCNs7-6J^@rB?djqeVj;LR*gHGe zQRH9Rvs<%UX4(0uM!a-$DOe~vpLMeD!m~==^-~|j7fyA}IOx9lm6Ohvg#lXx1J5`` zdA?s!E zhppavTl)Ps?A_YAeJ!`f(hsYuWE~f5Eneqy zrC9L8J?Zx{bG~1Qa-O>1S^v&~Ra}L^x(<_n&6TLwVacDoBDdxAFBPU3*4Y=i)aD$X z?9RWyqeE^+^yv#NQTOMm3l-aX@Lvho*|3r!eYx6Ve|L+j`3i3%@2nU3`1rfHOJGmd z(^dh?-)nqXT`D(OM%WyEwO6Q%xqG*I`h}&7QqFbsoP8tDm~8Utr;ORWwZYyi#cO(Q zYhNhITi5sfzj-H*#;H}zyIM8;F5Ldx^5TQY{ko+UED^F#3!1}+E~IJ)W)&m z;e}IO-itq4zW-p%a){+b?N^I?yN?|EeEi3b?fO$UFw{?t>6K{|i_KnZ+<4J)vh&lY z!55Z#Z(X`IJIHEsR&=PUc&TmoAumt(qf4UlT*_eUJArr^U9njoBaCg zFH7#8%NyLUWtdDmWT0dHYvz|H^Nw4q2(ipdnr58-BIrf J%Q~loCIE|2BSQcH literal 0 HcmV?d00001 diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_16x16@2x.png b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_16x16@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..ec8a213b94307b177a5c50ed076796305696cecd GIT binary patch literal 1847 zcmV-72gvw|P) zCZ!Zg!I-`@BodyDK}!RK2vR6F1H>q5R6rv^trrAI(0ECR2E;U)G|`Z1<1r`QH3fxUk4=2W;V`n51c^`UMEKk1t$QcX{4n7CtZJ0*bu$S9)g4czdo@c`A z&RNd=u*V1S`~7Xh!^2x_!SS}hf!^NU8Jec;8sBylV}TXsTmzW9W9glBmdiWG6OcIK z^gh3T58IFpr|~+Vl$Mq*fwy6=5JSK)=du=M(>XFtgoA}))XZX@F)5qP+J>-}!M=Q1 zBmrbxdDiKJ?r1dnIHdEDQ(2co>`XO}8BzrT-96{&boY7Mv|$w;SaX|(uG{`Txm%^X zXPNZ;&b?w-RsHnQd#};=XEXHmnG#mvkV36XMya7XZ0p2oCYxm&tOzc8Ji%DKa^=dw z`(v?ZctcKTnv}_8NY`~58A_2cnjyufP}ww{{OJF>d5f~((E-K!#?9NZ zvAK06Rh3Vt+_JZ2ZmB*BcDzETjt$c)^r)T4QI2oQ1s;?bpkf zKb;wiHH%g>46lg<3kHKUGBQerkwPL7l>u&kgp3=5WZ_X#aB@9big0CVbTBQ3^b(cA zk)R;sMfF;L{})wNRUGCF+wq0~scye;yurlq3%nTXL7jq{yi2$ZIEmjP{obg=HI>eg zkxEOfqtn77%Z=ACCgMz?g^3P0LL6y+f_nHC^jx5g>sQjDbwe~ffLJ@k589Ta5$fzZ zO-H}!kkv;Xet>ptdlo49M4sXGIw5dRIAj3=u6Od3BW?>8%%$4eYC&Sv2N5z7f||rt zMce}cKP_&2jOy#=hy&rUE_<%LlXQ;D46thFo*JJjUZ9sk3u2D z7Gx!m6L^fG0bSXktYY%Cg_JBf2Q{DPRLqishzbfse%v@DhNR)ogTG+=j@_$_S{8^pSwgYYnRg-Z|%?Df>zvu*tA*n$y(F( zQeRqkl0WVyq1Su!U#6gG?Q!t!@St|y+i;*mdf^5nto+qNCUc8XOq6*z!- z_S@sff2*snS7WhQ4U#9HBcjZ)z+tTcfsf^<&)}3Tn&L%FCfCh$giDWW?Yxl=jM~=ihJ3B9e z;VM4opUq*nyP}p)&V`*{8m=@vpWHAQCJ=+fiI&7Sj4$EL(T&q&N_mg}uehI#Z(LjP lh@Ie%A)hR2#ghNl%HOzT3>jQHmJk2{002ovPDHLkV1gp(W!nG% literal 0 HcmV?d00001 diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_256x256.png b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_256x256.png new file mode 100644 index 0000000000000000000000000000000000000000..fabfa6a5f0151ee470e2be1ad14eeb4938531572 GIT binary patch literal 27472 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 literal 0 HcmV?d00001 diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_256x256@2x.png b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_256x256@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..6c3e5d7fd18ebcbdc9afb5c746ea16209962b592 GIT binary patch literal 73119 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 literal 0 HcmV?d00001 diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_32x32.png b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..ec8a213b94307b177a5c50ed076796305696cecd GIT binary patch literal 1847 zcmV-72gvw|P) zCZ!Zg!I-`@BodyDK}!RK2vR6F1H>q5R6rv^trrAI(0ECR2E;U)G|`Z1<1r`QH3fxUk4=2W;V`n51c^`UMEKk1t$QcX{4n7CtZJ0*bu$S9)g4czdo@c`A z&RNd=u*V1S`~7Xh!^2x_!SS}hf!^NU8Jec;8sBylV}TXsTmzW9W9glBmdiWG6OcIK z^gh3T58IFpr|~+Vl$Mq*fwy6=5JSK)=du=M(>XFtgoA}))XZX@F)5qP+J>-}!M=Q1 zBmrbxdDiKJ?r1dnIHdEDQ(2co>`XO}8BzrT-96{&boY7Mv|$w;SaX|(uG{`Txm%^X zXPNZ;&b?w-RsHnQd#};=XEXHmnG#mvkV36XMya7XZ0p2oCYxm&tOzc8Ji%DKa^=dw z`(v?ZctcKTnv}_8NY`~58A_2cnjyufP}ww{{OJF>d5f~((E-K!#?9NZ zvAK06Rh3Vt+_JZ2ZmB*BcDzETjt$c)^r)T4QI2oQ1s;?bpkf zKb;wiHH%g>46lg<3kHKUGBQerkwPL7l>u&kgp3=5WZ_X#aB@9big0CVbTBQ3^b(cA zk)R;sMfF;L{})wNRUGCF+wq0~scye;yurlq3%nTXL7jq{yi2$ZIEmjP{obg=HI>eg zkxEOfqtn77%Z=ACCgMz?g^3P0LL6y+f_nHC^jx5g>sQjDbwe~ffLJ@k589Ta5$fzZ zO-H}!kkv;Xet>ptdlo49M4sXGIw5dRIAj3=u6Od3BW?>8%%$4eYC&Sv2N5z7f||rt zMce}cKP_&2jOy#=hy&rUE_<%LlXQ;D46thFo*JJjUZ9sk3u2D z7Gx!m6L^fG0bSXktYY%Cg_JBf2Q{DPRLqishzbfse%v@DhNR)ogTG+=j@_$_S{8^pSwgYYnRg-Z|%?Df>zvu*tA*n$y(F( zQeRqkl0WVyq1Su!U#6gG?Q!t!@St|y+i;*mdf^5nto+qNCUc8XOq6*z!- z_S@sff2*snS7WhQ4U#9HBcjZ)z+tTcfsf^<&)}3Tn&L%FCfCh$giDWW?Yxl=jM~=ihJ3B9e z;VM4opUq*nyP}p)&V`*{8m=@vpWHAQCJ=+fiI&7Sj4$EL(T&q&N_mg}uehI#Z(LjP lh@Ie%A)hR2#ghNl%HOzT3>jQHmJk2{002ovPDHLkV1gp(W!nG% literal 0 HcmV?d00001 diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_32x32@2x.png b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_32x32@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..eba2103f0429edf22f27c8a3209f5a49af043a82 GIT binary patch literal 4522 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 IM6N<$g8Wss*Z=?k literal 0 HcmV?d00001 diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_512x512.png b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..6c3e5d7fd18ebcbdc9afb5c746ea16209962b592 GIT binary patch literal 73119 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 literal 0 HcmV?d00001 diff --git a/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_512x512@2x.png b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_512x512@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..52e7f4c272c4a21f10bdf5e34681de05c0627fbb GIT binary patch literal 208821 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

BG&vVPZ*2P**epTM*W?MBHw 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

I!b z<$RMu($gE~op`TTMRGJ&*V5|G+NY@6f;|d|=^zM=*&1im;lIQDw5ER}WEH@?-KYs^ z+&M#(`o+AORdp0S(L?lRPXCTA#=+=&Xn`1!4X{;RC(!sO;rTl-b3)8>Y*YQhl7gZL z23_79UW{XcocjiaE9cM zBMu&8OI~0OS`bChRRqJezz}8Bc?21bVpqB3$aSI7h3<&)TgYkK32+s^BpniLZo4tQ zW3~wdu$@+a^}1RfZIe<<3@+PsVbNgDZ35V`*A^AO0G0g;)C~4sZT@I`82F>^XjR~M z)@hOi?}l#PNP}jmP|}Z^o^o416cp?mfvQ>it=vX{Lk5c@IvQNNR;-?u2t555wEaU< zw5D+6Rq1vM4HqoAD*_=#aj?VA`)xX7cu0{(!H<%51=yJF4^vQWOi89TK2t^;XAZ

RpNjmTzFu&jvzRZr!5Qs{3?U{j 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 literal 0 HcmV?d00001 From 25c1566f31a6c7c099551980906a9fc88bf7ef0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 16:31:33 +0100 Subject: [PATCH 032/232] Makes NSEdgeInsets conform to Equatable --- .../Runestone/MultiPlatform/MultiPlatformEdgeInsets.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/Runestone/MultiPlatform/MultiPlatformEdgeInsets.swift b/Sources/Runestone/MultiPlatform/MultiPlatformEdgeInsets.swift index 3e2057da6..b2010eadf 100644 --- a/Sources/Runestone/MultiPlatform/MultiPlatformEdgeInsets.swift +++ b/Sources/Runestone/MultiPlatform/MultiPlatformEdgeInsets.swift @@ -12,4 +12,10 @@ extension NSEdgeInsets { NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) } } + +extension NSEdgeInsets: Equatable { + public static func == (lhs: NSEdgeInsets, rhs: NSEdgeInsets) -> Bool { + lhs.left == rhs.left && lhs.top == rhs.top && lhs.right == rhs.right && lhs.bottom == rhs.bottom + } +} #endif From 3d4156f9520a413d1780ad5644e46e2770d4ea94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 16:31:40 +0100 Subject: [PATCH 033/232] Adds layoutIfNeeded() to NSView --- Sources/Runestone/MultiPlatform/MultiPlatformView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Runestone/MultiPlatform/MultiPlatformView.swift b/Sources/Runestone/MultiPlatform/MultiPlatformView.swift index 66bfb3263..288be1d4e 100644 --- a/Sources/Runestone/MultiPlatform/MultiPlatformView.swift +++ b/Sources/Runestone/MultiPlatform/MultiPlatformView.swift @@ -31,6 +31,10 @@ extension NSView { func setNeedsLayout() { needsLayout = true } + + func layoutIfNeeded() { + layoutSubtreeIfNeeded() + } } func UIGraphicsGetCurrentContext() -> CGContext? { From 79bfbf53aeb847e9840c7f2cc5645b6617aaab26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 16:32:26 +0100 Subject: [PATCH 034/232] Adds LineMovementController.Direction --- .../Core/iOS/LineMovementController.swift | 35 ++++++++++++++++--- .../Core/iOS/TextView_iOS+UITextInput.swift | 1 + 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift b/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift index 079d1bcb4..63d84b6df 100644 --- a/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift +++ b/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift @@ -1,7 +1,16 @@ +import Foundation #if os(iOS) import UIKit +#endif final class LineMovementController { + enum Direction { + case left + case right + case up + case down + } + var lineManager: LineManager var stringView: StringView let lineControllerStorage: LineControllerStorage @@ -12,8 +21,8 @@ final class LineMovementController { self.lineControllerStorage = lineControllerStorage } - func location(from location: Int, in direction: UITextLayoutDirection, offset: Int) -> Int? { - let newLocation: Int? + func location(from location: Int, in direction: Direction, offset: Int) -> Int? { + let newLocation: Int switch direction { case .left: newLocation = locationForMoving(fromLocation: location, by: offset * -1) @@ -23,10 +32,8 @@ final class LineMovementController { newLocation = locationForMoving(lineOffset: offset * -1, fromLineContainingCharacterAt: location) case .down: newLocation = locationForMoving(lineOffset: offset, fromLineContainingCharacterAt: location) - @unknown default: - newLocation = nil } - if let newLocation = newLocation, newLocation >= 0 && newLocation <= stringView.string.length { + if newLocation >= 0 && newLocation <= stringView.string.length { return newLocation } else { return nil @@ -145,4 +152,22 @@ private extension LineMovementController { return lineController.numberOfLineFragments } } + +#if os(iOS) +extension LineMovementController.Direction { + init(_ direction: UITextLayoutDirection) { + switch direction { + case .right: + self = .right + case .left: + self = .left + case .up: + self = .up + case .down: + self = .down + @unknown default: + self = .down + } + } +} #endif diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index 4492cce47..68e20578f 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -284,6 +284,7 @@ public extension TextView { return nil } didCallPositionFromPositionInDirectionWithOffset = true + let direction = LineMovementController.Direction(direction) guard let newLocation = textViewController.lineMovementController.location( from: indexedPosition.index, in: direction, From 8ff5426d216f6a43c45c3b59f25bc23675bfd4ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 16:32:41 +0100 Subject: [PATCH 035/232] Uses MultiPlatformEdgeInsets --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 783ec6c79..5f3b342d2 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -333,7 +333,7 @@ open class TextView: UIScrollView { } } /// The amount of spacing surrounding the lines. - public var textContainerInset: UIEdgeInsets { + public var textContainerInset: MultiPlatformEdgeInsets { get { return textViewController.textContainerInset } From 0440c21881605f4088bfc4a13bf13c1631a783b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 16:32:58 +0100 Subject: [PATCH 036/232] Makes iOS TextView available on iOS-only --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 5f3b342d2..40d880892 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -1,3 +1,4 @@ +#if os(iOS) // swiftlint:disable file_length type_body_length import CoreText import UIKit @@ -1318,3 +1319,4 @@ extension TextView: HighlightNavigationControllerDelegate { } } } +#endif From 54e2b92171faa823016c4ef7417bf1752c9e1fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 16:33:07 +0100 Subject: [PATCH 037/232] Uses LineMovementController.Direction --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 40d880892..24da01d39 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -1177,7 +1177,7 @@ private extension TextView { } } - private func moveCaret(_ direction: UITextLayoutDirection) { + private func moveCaret(_ direction: LineMovementController.Direction) { guard let selectedRange = textViewController.selectedRange else { return } From 72255dc84ee5549566908bf652bb3c1c0b89171d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 16:33:55 +0100 Subject: [PATCH 038/232] Passes scroll view to TextViewController --- .../TextViewController+ContentSize.swift | 18 ++++++----- .../TextViewController+Scrolling.swift | 18 +++++------ .../TextViewController.swift | 31 ++++++++++--------- .../TextView/Core/iOS/TextView_iOS.swift | 3 +- 4 files changed, 37 insertions(+), 33 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift index 248a28e18..a6259e679 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift @@ -11,7 +11,7 @@ extension TextViewController { } func invalidateContentSizeIfNeeded() { - if textView.contentSize != contentSize { + if scrollView.contentSize != contentSize { hasPendingContentSizeUpdate = true handleContentSizeUpdateIfNeeded() } @@ -25,18 +25,20 @@ extension TextViewController { // or at the end of a line since it causes flickering when updating the content size while scrolling. // However, we do allow updating the content size if the text view is scrolled far enough on // the y-axis as that means it will soon run out of text to display. - let isBouncingAtGutter = textView.contentOffset.x < -textView.contentInset.left - let isBouncingAtLineEnd = textView.contentOffset.x > textView.contentSize.width - textView.frame.size.width + textView.contentInset.right + let gutterBounceOffset = scrollView.contentInset.left * -1 + let lineEndBounceOffset = scrollView.contentSize.width - scrollView.frame.size.width + scrollView.contentInset.right + let isBouncingAtGutter = scrollView.contentOffset.x < gutterBounceOffset + let isBouncingAtLineEnd = scrollView.contentOffset.x > lineEndBounceOffset let isBouncingHorizontally = isBouncingAtGutter || isBouncingAtLineEnd - let isCriticalUpdate = textView.contentOffset.y > textView.contentSize.height - textView.frame.height * 1.5 - let isScrolling = textView.isDragging || textView.isDecelerating + let isCriticalUpdate = scrollView.contentOffset.y > scrollView.contentSize.height - scrollView.frame.height * 1.5 + let isScrolling = scrollView.isDragging || scrollView.isDecelerating guard !isBouncingHorizontally || isCriticalUpdate || !isScrolling else { return } hasPendingContentSizeUpdate = false - let oldContentOffset = textView.contentOffset - textView.contentSize = contentSize - textView.contentOffset = oldContentOffset + let oldContentOffset = scrollView.contentOffset + scrollView.contentSize = contentSize + scrollView.contentOffset = oldContentOffset textView.setNeedsLayout() } } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift index 0c31b3f6c..dc4bdf25f 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift @@ -21,7 +21,7 @@ private extension TextViewController { let rectMinY = min(lowerBoundRect.minY, upperBoundRect.minY) let rectMaxY = max(lowerBoundRect.maxY, upperBoundRect.maxY) let rect = CGRect(x: rectMinX, y: rectMinY, width: rectMaxX - rectMinX, height: rectMaxY - rectMinY) - textView.contentOffset = contentOffsetForScrollingToVisibleRect(rect) + scrollView.contentOffset = contentOffsetForScrollingToVisibleRect(rect) } private func caretRect(at location: Int) -> CGRect { @@ -35,13 +35,13 @@ private extension TextViewController { /// - Returns: The content offset to scroll to. private func contentOffsetForScrollingToVisibleRect(_ rect: CGRect) -> CGPoint { // Create the viewport: a rectangle containing the content that is visible to the user. - var viewport = CGRect(x: textView.contentOffset.x, y: textView.contentOffset.y, width: textView.frame.width, height: textView.frame.height) - viewport.origin.y += textView.adjustedContentInset.top - viewport.origin.x += textView.adjustedContentInset.left + gutterWidth - viewport.size.width -= textView.adjustedContentInset.left + textView.adjustedContentInset.right + gutterWidth - viewport.size.height -= textView.adjustedContentInset.top + textView.adjustedContentInset.bottom + var viewport = CGRect(origin: scrollView.contentOffset, size: textView.frame.size) + viewport.origin.y += scrollView.adjustedContentInset.top + viewport.origin.x += scrollView.adjustedContentInset.left + gutterWidth + viewport.size.width -= scrollView.adjustedContentInset.left + scrollView.adjustedContentInset.right + gutterWidth + viewport.size.height -= scrollView.adjustedContentInset.top + scrollView.adjustedContentInset.bottom // Construct the best possible content offset. - var newContentOffset = textView.contentOffset + var newContentOffset = scrollView.contentOffset if rect.minX < viewport.minX { newContentOffset.x -= viewport.minX - rect.minX } else if rect.maxX > viewport.maxX && rect.width <= viewport.width { @@ -58,8 +58,8 @@ private extension TextViewController { } else if rect.maxY > viewport.maxY { newContentOffset.y += rect.minY } - let cappedXOffset = min(max(newContentOffset.x, textView.minimumContentOffset.x), textView.maximumContentOffset.x) - let cappedYOffset = min(max(newContentOffset.y, textView.minimumContentOffset.y), textView.maximumContentOffset.y) + let cappedXOffset = min(max(newContentOffset.x, scrollView.minimumContentOffset.x), scrollView.maximumContentOffset.x) + let cappedYOffset = min(max(newContentOffset.y, scrollView.minimumContentOffset.y), scrollView.maximumContentOffset.y) return CGPoint(x: cappedXOffset, y: cappedYOffset) } } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 39a0f008f..d300a6b5e 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -3,18 +3,21 @@ import Foundation final class TextViewController { var textView: TextView { - get { - if let textView = _textView { - return textView - } else { - fatalError("Text view has been deallocated or has not been assigned") - } + if let textView = _textView { + return textView + } else { + fatalError("The text view has been deallocated or has not been assigned") } - set { - _textView = newValue + } + var scrollView: MultiPlatformScrollView { + if let scrollView = _scrollView { + return scrollView + } else { + fatalError("The scroll view has been deallocated or has not been assigned") } } private weak var _textView: TextView? + private weak var _scrollView: MultiPlatformScrollView? var selectedRange: NSRange? { didSet { if selectedRange != oldValue { @@ -507,8 +510,9 @@ final class TextViewController { } private var cancellables: Set = [] - init(textView: TextView) { + init(textView: TextView, scrollView: MultiPlatformScrollView) { _textView = textView + _scrollView = scrollView lineManager = LineManager(stringView: stringView) highlightService = HighlightService(lineManager: lineManager) lineControllerFactory = LineControllerFactory( @@ -688,12 +692,11 @@ extension TextViewController: TreeSitterLanguageModeDelegate { // MARK: - LayoutManagerDelegate extension TextViewController: LayoutManagerDelegate { func layoutManager(_ layoutManager: LayoutManager, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) { - let isScrolling = textView.isDragging || textView.isDecelerating + let isScrolling = scrollView.isDragging || scrollView.isDecelerating if contentOffsetAdjustment != .zero && isScrolling { - textView.contentOffset = CGPoint( - x: textView.contentOffset.x + contentOffsetAdjustment.x, - y: textView.contentOffset.y + contentOffsetAdjustment.y - ) + let newXOffset = scrollView.contentOffset.x + contentOffsetAdjustment.x + let newYOffset = scrollView.contentOffset.y + contentOffsetAdjustment.y + scrollView.contentOffset = CGPoint(x: newXOffset, y: newYOffset) } } } diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 24da01d39..d1d3cf3f4 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -507,7 +507,7 @@ open class TextView: UIScrollView { } } - private(set) lazy var textViewController = TextViewController(textView: self) + private(set) lazy var textViewController = TextViewController(textView: self, scrollView: self) private(set) lazy var customTokenizer = TextInputStringTokenizer( textInput: self, stringView: textViewController.stringView, @@ -561,7 +561,6 @@ open class TextView: UIScrollView { public override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .white - textViewController.textView = self editableTextInteraction.textInput = self nonEditableTextInteraction.textInput = self editableTextInteraction.delegate = self From 3270f0a2d106439c6c0385be200b4a51b0fb9635 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 29 Jan 2023 16:34:14 +0100 Subject: [PATCH 039/232] Handles isSelectable in hitTest(_:with:) --- .../TextViewController.swift | 19 ++++++++----------- .../TextView/Core/iOS/TextView_iOS.swift | 3 +++ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index d300a6b5e..8f731fcb8 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -46,17 +46,14 @@ final class TextViewController { } var isSelectable = true { didSet { - if isSelectable != oldValue { - textView.isUserInteractionEnabled = isSelectable - if !isSelectable && isEditing { - textView.resignFirstResponder() - selectedRange = nil - #if os(iOS) - textView.handleTextSelectionChange() - #endif - isEditing = false - textView.editorDelegate?.textViewDidEndEditing(textView) - } + if isSelectable != oldValue && !isSelectable && isEditing { + textView.resignFirstResponder() + selectedRange = nil + #if os(iOS) + textView.handleTextSelectionChange() + #endif + isEditing = false + textView.editorDelegate?.textViewDidEndEditing(textView) } } } diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index d1d3cf3f4..ac67505a1 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -983,6 +983,9 @@ open class TextView: UIScrollView { /// - event: The event that warranted a call to this method. If you are calling this method from outside your event-handling code, you may specify nil. /// - Returns: The view object that is the farthest descendent of the current view and contains point. Returns nil if the point lies completely outside the receiver’s view hierarchy. open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard isSelectable else { + return nil + } // We end our current undo group when the user touches the view. let result = super.hitTest(point, with: event) if result === self { From 3a2394df7de9540d6b4d4d7d2714bdb79a5b8d58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 30 Jan 2023 14:59:43 +0100 Subject: [PATCH 040/232] Removes TextInputClientView --- .../Core/Mac/TextInputClientView.swift | 54 ------------------- 1 file changed, 54 deletions(-) delete mode 100644 Sources/Runestone/TextView/Core/Mac/TextInputClientView.swift diff --git a/Sources/Runestone/TextView/Core/Mac/TextInputClientView.swift b/Sources/Runestone/TextView/Core/Mac/TextInputClientView.swift deleted file mode 100644 index 97f4b21c4..000000000 --- a/Sources/Runestone/TextView/Core/Mac/TextInputClientView.swift +++ /dev/null @@ -1,54 +0,0 @@ -#if os(macOS) -import AppKit - -final class TextInputClientView: NSView, NSTextInputClient { - override var acceptsFirstResponder: Bool { - return true - } - - override func doCommand(by selector: Selector) { - print(selector) - } - - func insertText(_ string: Any, replacementRange: NSRange) { - print(string) - } - - func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { - - } - - func unmarkText() { - - } - - func selectedRange() -> NSRange { - NSRange(location: 0, length: 0) - } - - func markedRange() -> NSRange { - NSRange(location: 0, length: 0) - } - - func hasMarkedText() -> Bool { - false - } - - func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { - nil - } - - func validAttributesForMarkedText() -> [NSAttributedString.Key] { - [] - } - - func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { - .zero - } - - func characterIndex(for point: NSPoint) -> Int { - 0 - } -} - -#endif From aa5100be23ba94c4f10394383f977d7dda479fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 30 Jan 2023 15:01:36 +0100 Subject: [PATCH 041/232] Adds scrollContentView --- .../TextViewController/TextViewController.swift | 13 +++++++++++-- .../Runestone/TextView/Core/iOS/TextView_iOS.swift | 6 +++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 8f731fcb8..972221c83 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -16,8 +16,16 @@ final class TextViewController { fatalError("The scroll view has been deallocated or has not been assigned") } } + var scrollContentView: MultiPlatformView { + if let scrollContentView = _scrollContentView { + return scrollContentView + } else { + fatalError("The scroll content view has been deallocated or has not been assigned") + } + } private weak var _textView: TextView? private weak var _scrollView: MultiPlatformScrollView? + private weak var _scrollContentView: MultiPlatformView? var selectedRange: NSRange? { didSet { if selectedRange != oldValue { @@ -507,9 +515,10 @@ final class TextViewController { } private var cancellables: Set = [] - init(textView: TextView, scrollView: MultiPlatformScrollView) { + init(textView: TextView, scrollView: MultiPlatformScrollView, scrollContentView: MultiPlatformView) { _textView = textView _scrollView = scrollView + _scrollContentView = scrollContentView lineManager = LineManager(stringView: stringView) highlightService = HighlightService(lineManager: lineManager) lineControllerFactory = LineControllerFactory( @@ -565,7 +574,7 @@ final class TextViewController { lineControllerStorage: lineControllerStorage ) layoutManager.delegate = self - layoutManager.containerView = textView + layoutManager.containerView = scrollContentView applyThemeToChildren() indentController.delegate = self lineControllerStorage.delegate = self diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index ac67505a1..1c125f04c 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -507,7 +507,11 @@ open class TextView: UIScrollView { } } - private(set) lazy var textViewController = TextViewController(textView: self, scrollView: self) + private(set) lazy var textViewController = TextViewController( + textView: self, + scrollView: self, + scrollContentView: self + ) private(set) lazy var customTokenizer = TextInputStringTokenizer( textInput: self, stringView: textViewController.stringView, From 81e85c39bd5eb79ff556218c000eac1e75d95434 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 30 Jan 2023 15:02:12 +0100 Subject: [PATCH 042/232] Removes background color --- Example/MacExample/MainViewController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index db937044f..4f6e788ef 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -14,8 +14,6 @@ final class MainViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() - view.wantsLayer = true - view.layer?.backgroundColor = NSColor.purple.cgColor setupTextView() } } From 32a9447de0f1eb4c390189fb94fb81996c247c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 30 Jan 2023 15:02:20 +0100 Subject: [PATCH 043/232] Removes return --- Example/MacExample/MainViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index 4f6e788ef..a771a5b92 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -9,7 +9,7 @@ final class MainViewController: NSViewController { }() override var acceptsFirstResponder: Bool { - return true + true } override func viewDidLoad() { From 2070d269c3c5b6691f7fdfc79bab9fff3ec55c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 30 Jan 2023 16:30:46 +0100 Subject: [PATCH 044/232] Updates window title --- Example/MacExample/Base.lproj/Main.storyboard | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/MacExample/Base.lproj/Main.storyboard b/Example/MacExample/Base.lproj/Main.storyboard index eae9f485c..36cc0b05d 100644 --- a/Example/MacExample/Base.lproj/Main.storyboard +++ b/Example/MacExample/Base.lproj/Main.storyboard @@ -685,7 +685,7 @@ - + From 920d19ef016ae158d28a1b9b3891ddc09259eb08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 30 Jan 2023 16:30:53 +0100 Subject: [PATCH 045/232] Uses UIEdgeInsets --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 1c125f04c..1a1ccbaa8 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -334,7 +334,7 @@ open class TextView: UIScrollView { } } /// The amount of spacing surrounding the lines. - public var textContainerInset: MultiPlatformEdgeInsets { + public var textContainerInset: UIEdgeInsets { get { return textViewController.textContainerInset } From e00ea33a592459e7480e32eed1e039aff8a56940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 30 Jan 2023 16:31:28 +0100 Subject: [PATCH 046/232] Renames layout() to layoutIfNeeded() --- .../Core/TextViewController/TextViewController+Layout.swift | 2 +- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift index 8d7d8736c..c6b233564 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift @@ -14,7 +14,7 @@ extension TextViewController { } } - func layout() { + func layoutIfNeeded() { layoutManager.layoutIfNeeded() layoutManager.layoutLineSelectionIfNeeded() layoutPageGuideIfNeeded() diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 1a1ccbaa8..912125247 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -597,7 +597,7 @@ open class TextView: UIScrollView { super.layoutSubviews() hasDeletedTextWithPendingLayoutSubviews = false textViewController.scrollViewWidth = frame.width - textViewController.layout() + textViewController.layoutIfNeeded() // 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. From 9c707595742b9b17829a7360ecc2eb214e33f59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 30 Jan 2023 16:32:56 +0100 Subject: [PATCH 047/232] WIP AppKit --- Example/MacExample/MainViewController.swift | 1 + .../Library/Mac/NSScrollView+Helpers.swift | 49 ++ .../TextView/Core/LayoutManager.swift | 2 +- .../TextView/Core/LineFragmentView.swift | 2 +- .../TextView/Core/Mac/FlippedView.swift | 9 + .../Mac/TextView_Mac+NSTextInputClient.swift | 91 ++++ .../TextView/Core/Mac/TextView_Mac.swift | 443 ++++++++++++++++-- .../TextViewController.swift | 4 + 8 files changed, 561 insertions(+), 40 deletions(-) create mode 100644 Sources/Runestone/Library/Mac/NSScrollView+Helpers.swift create mode 100644 Sources/Runestone/TextView/Core/Mac/FlippedView.swift create mode 100644 Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index a771a5b92..7e24e0a97 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -5,6 +5,7 @@ final class MainViewController: NSViewController { private let textView: TextView = { let this = TextView() this.translatesAutoresizingMaskIntoConstraints = false + this.textContainerInset = NSEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) return this }() diff --git a/Sources/Runestone/Library/Mac/NSScrollView+Helpers.swift b/Sources/Runestone/Library/Mac/NSScrollView+Helpers.swift new file mode 100644 index 000000000..da4f7ed73 --- /dev/null +++ b/Sources/Runestone/Library/Mac/NSScrollView+Helpers.swift @@ -0,0 +1,49 @@ +#if os(macOS) +import AppKit + +extension MultiPlatformScrollView { + var contentSize: CGSize { + get { + documentView?.frame.size ?? .zero + } + set { + documentView?.frame.size = newValue + } + } + + var contentOffset: CGPoint { + get { + return documentVisibleRect.origin + } + set { + documentView?.scroll(newValue) + } + } + + var contentInset: NSEdgeInsets { + .zero + } + + var adjustedContentInset: NSEdgeInsets { + .zero + } + + var minimumContentOffset: CGPoint { + CGPoint(x: adjustedContentInset.left * -1, y: adjustedContentInset.top * -1) + } + + var maximumContentOffset: CGPoint { + let maxX = max(contentSize.width - bounds.width, 0) + let maxY = max(contentSize.height - bounds.height, 0) + return CGPoint(x: maxX, y: maxY) + } + + var isDragging: Bool { + false + } + + var isDecelerating: Bool { + false + } +} +#endif diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index 2d7df2a6d..ae3f2824c 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -112,7 +112,7 @@ final class LayoutManager { private var lineFragmentViewReuseQueue = ViewReuseQueue() private var lineNumberLabelReuseQueue = ViewReuseQueue() private var visibleLineIDs: Set = [] - private let linesContainerView = MultiPlatformView() + private let linesContainerView = FlippedView() private let gutterBackgroundView = GutterBackgroundView() private let lineNumbersContainerView = MultiPlatformView() private let gutterSelectionBackgroundView = MultiPlatformView() diff --git a/Sources/Runestone/TextView/Core/LineFragmentView.swift b/Sources/Runestone/TextView/Core/LineFragmentView.swift index 69424212b..e0afbb41b 100644 --- a/Sources/Runestone/TextView/Core/LineFragmentView.swift +++ b/Sources/Runestone/TextView/Core/LineFragmentView.swift @@ -5,7 +5,7 @@ import AppKit import UIKit #endif -final class LineFragmentView: MultiPlatformView, ReusableView { +final class LineFragmentView: FlippedView, ReusableView { var renderer: LineFragmentRenderer? { didSet { if renderer !== oldValue { diff --git a/Sources/Runestone/TextView/Core/Mac/FlippedView.swift b/Sources/Runestone/TextView/Core/Mac/FlippedView.swift new file mode 100644 index 000000000..847d0f38c --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/FlippedView.swift @@ -0,0 +1,9 @@ +import Foundation + +class FlippedView: MultiPlatformView { + #if os(macOS) + override var isFlipped: Bool { + true + } + #endif +} diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift new file mode 100644 index 000000000..816f8686c --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift @@ -0,0 +1,91 @@ +#if os(macOS) +import AppKit + +extension TextView: NSTextInputClient { + public override func doCommand(by selector: Selector) { + if selector == NSSelectorFromString("deleteBackward:") { + deleteBackward() + } else if selector == NSSelectorFromString("insertNewline:") { + textViewController.indentController.insertLineBreak(in: textViewController.rangeForInsertingText, using: lineEndings) + } else { + #if DEBUG + print(NSStringFromSelector(selector)) + #endif + super.doCommand(by: selector) + } + } + + public func insertText(_ string: Any, replacementRange: NSRange) { + guard let string = string as? String else { + return + } + if replacementRange.location == NSNotFound { + textViewController.replaceText(in: textViewController.rangeForInsertingText, with: string) + } else { + textViewController.replaceText(in: replacementRange, with: string) + } + } + + public func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {} + + public func unmarkText() {} + + public func selectedRange() -> NSRange { + textViewController.selectedRange ?? NSRange(location: 0, length: 0) + } + + public func markedRange() -> NSRange { + textViewController.markedRange ?? NSRange(location: 0, length: 0) + } + + public func hasMarkedText() -> Bool { + (textViewController.markedRange?.length ?? 0) > 0 + } + + public func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { + nil + } + + public func validAttributesForMarkedText() -> [NSAttributedString.Key] { + [] + } + + public func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { + .zero + } + + public func characterIndex(for point: NSPoint) -> Int { + 0 + } +} + +private extension TextView { + private func deleteBackward() { + guard var selectedRange = textViewController.markedRange ?? textViewController.selectedRange else { + return + } + if selectedRange.length == 0 { + selectedRange.location -= 1 + selectedRange.length = 1 + } + let deleteRange = textViewController.rangeForDeletingText(in: selectedRange) + // If we're deleting everything in the marked range then we clear the marked range. UITextInput doesn't do that for us. + // Can be tested by entering a backtick (`) in an empty document and deleting it. + if deleteRange == textViewController.markedRange { + textViewController.markedRange = nil + } + guard textViewController.shouldChangeText(in: deleteRange, replacementText: "") else { + return + } + let isDeletingMultipleCharacters = selectedRange.length > 1 + if isDeletingMultipleCharacters { + undoManager?.endUndoGrouping() + undoManager?.beginUndoGrouping() + } + textViewController.replaceText(in: deleteRange, with: "", selectedRangeAfterUndo: selectedRange) + if isDeletingMultipleCharacters { + undoManager?.endUndoGrouping() + } + } +} +#endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 26a12a986..b3b1a8e88 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -1,40 +1,407 @@ #if os(macOS) -//import AppKit -// -//public final class TextView: NSView { -// private let textInputClientView: TextInputClientView = { -// let this = TextInputClientView() -// this.translatesAutoresizingMaskIntoConstraints = false -// return this -// }() -// -// public init() { -// super.init(frame: .zero) -// wantsLayer = true -// } -// -// required init?(coder: NSCoder) { -// fatalError("init(coder:) has not been implemented") -// } -// -// public override func keyDown(with event: NSEvent) { -// NSCursor.setHiddenUntilMouseMoves(true) -// let didInputContextHandleEvent = inputContext?.handleEvent(event) ?? false -// if !didInputContextHandleEvent { -// super.keyDown(with: event) -// } -// } -//} -// -//private extension TextView { -// private func setupTextInputClientView() { -// addSubview(textInputClientView) -// NSLayoutConstraint.activate([ -// textInputClientView.leadingAnchor.constraint(equalTo: leadingAnchor), -// textInputClientView.trailingAnchor.constraint(equalTo: trailingAnchor), -// textInputClientView.topAnchor.constraint(equalTo: topAnchor), -// textInputClientView.bottomAnchor.constraint(equalTo: bottomAnchor) -// ]) -// } -//} +import AppKit + +public final class TextView: NSView { + public weak var editorDelegate: TextViewDelegate? + public override var acceptsFirstResponder: Bool { + true + } + /// Character pairs are used by the editor to automatically insert a trailing character when the user types the leading character. + /// + /// Common usages of this includes the \" character to surround strings and { } to surround a scope. + public var characterPairs: [CharacterPair] { + get { + return textViewController.characterPairs + } + set { + textViewController.characterPairs = newValue + } + } + /// Determines what should happen to the trailing component of a character pair when deleting the leading component. Defaults to `disabled` meaning that nothing will happen. + public var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode { + get { + return textViewController.characterPairTrailingComponentDeletionMode + } + set { + textViewController.characterPairTrailingComponentDeletionMode = newValue + } + } + /// Enable to show line numbers in the gutter. + public var showLineNumbers: Bool { + get { + return textViewController.showLineNumbers + } + set { + textViewController.showLineNumbers = newValue + } + } + /// Enable to show highlight the selected lines. The selection is only shown in the gutter when multiple lines are selected. + public var lineSelectionDisplayType: LineSelectionDisplayType { + get { + return textViewController.lineSelectionDisplayType + } + set { + textViewController.lineSelectionDisplayType = newValue + } + } + /// The text view renders invisible tabs when enabled. The `tabsSymbol` is used to render tabs. + public var showTabs: Bool { + get { + return textViewController.showTabs + } + set { + textViewController.showTabs = newValue + } + } + /// The text view renders invisible spaces when enabled. + /// + /// The `spaceSymbol` is used to render spaces. + public var showSpaces: Bool { + get { + return textViewController.showSpaces + } + set { + textViewController.showSpaces = newValue + } + } + /// The text view renders invisible spaces when enabled. + /// + /// The `nonBreakingSpaceSymbol` is used to render spaces. + public var showNonBreakingSpaces: Bool { + get { + return textViewController.showNonBreakingSpaces + } + set { + textViewController.showNonBreakingSpaces = newValue + } + } + /// The text view renders invisible line breaks when enabled. + /// + /// The `lineBreakSymbol` is used to render line breaks. + public var showLineBreaks: Bool { + get { + return textViewController.showLineBreaks + } + set { + textViewController.showLineBreaks = newValue + } + } + /// The text view renders invisible soft line breaks when enabled. + /// + /// The `softLineBreakSymbol` is used to render line breaks. These line breaks are typically represented by the U+2028 unicode character. Runestone does not provide any key commands for inserting these but supports rendering them. + public var showSoftLineBreaks: Bool { + get { + return textViewController.showSoftLineBreaks + } + set { + textViewController.showSoftLineBreaks = newValue + } + } + /// Symbol used to display tabs. + /// + /// The value is only used when invisible tab characters is enabled. The default is ▸. + /// + /// Common characters for this symbol include ▸, ⇥, ➜, ➞, and ❯. + public var tabSymbol: String { + get { + return textViewController.tabSymbol + } + set { + textViewController.tabSymbol = newValue + } + } + /// Symbol used to display spaces. + /// + /// The value is only used when showing invisible space characters is enabled. The default is ·. + /// + /// Common characters for this symbol include ·, •, and _. + public var spaceSymbol: String { + get { + return textViewController.spaceSymbol + } + set { + textViewController.spaceSymbol = newValue + } + } + /// Symbol used to display non-breaking spaces. + /// + /// The value is only used when showing invisible space characters is enabled. The default is ·. + /// + /// Common characters for this symbol include ·, •, and _. + public var nonBreakingSpaceSymbol: String { + get { + return textViewController.nonBreakingSpaceSymbol + } + set { + textViewController.nonBreakingSpaceSymbol = newValue + } + } + /// Symbol used to display line break. + /// + /// The value is only used when showing invisible line break characters is enabled. The default is ¬. + /// + /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. + public var lineBreakSymbol: String { + get { + return textViewController.lineBreakSymbol + } + set { + textViewController.lineBreakSymbol = newValue + } + } + /// Symbol used to display soft line breaks. + /// + /// The value is only used when showing invisible soft line break characters is enabled. The default is ¬. + /// + /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. + public var softLineBreakSymbol: String { + get { + return textViewController.softLineBreakSymbol + } + set { + textViewController.softLineBreakSymbol = newValue + } + } + /// The strategy used when indenting text. + public var indentStrategy: IndentStrategy { + get { + return textViewController.indentStrategy + } + set { + textViewController.indentStrategy = newValue + } + } + /// The amount of padding before the line numbers inside the gutter. + public var gutterLeadingPadding: CGFloat { + get { + return textViewController.gutterLeadingPadding + } + set { + textViewController.gutterLeadingPadding = newValue + } + } + /// The amount of padding after the line numbers inside the gutter. + public var gutterTrailingPadding: CGFloat { + get { + return textViewController.gutterTrailingPadding + } + set { + textViewController.gutterTrailingPadding = newValue + } + } + /// The minimum amount of characters to use for width calculation inside the gutter. + public var gutterMinimumCharacterCount: Int { + get { + return textViewController.gutterMinimumCharacterCount + } + set { + textViewController.gutterMinimumCharacterCount = newValue + } + } + /// The amount of spacing surrounding the lines. + public var textContainerInset: NSEdgeInsets { + get { + return textViewController.textContainerInset + } + set { + textViewController.textContainerInset = newValue + } + } + /// When line wrapping is disabled, users can scroll the text view horizontally to see the entire line. + /// + /// Line wrapping is enabled by default. + public var isLineWrappingEnabled: Bool { + get { + return textViewController.isLineWrappingEnabled + } + set { + textViewController.isLineWrappingEnabled = newValue + } + } + /// Line break mode for text view. The default value is .byWordWrapping meaning that wrapping occurs on word boundaries. + public var lineBreakMode: LineBreakMode { + get { + return textViewController.lineBreakMode + } + set { + textViewController.lineBreakMode = newValue + } + } + /// Width of the gutter. + public var gutterWidth: CGFloat { + textViewController.gutterWidth + } + /// The line-height is multiplied with the value. + public var lineHeightMultiplier: CGFloat { + get { + return textViewController.lineHeightMultiplier + } + set { + textViewController.lineHeightMultiplier = newValue + } + } + /// The number of points by which to adjust kern. The default value is 0 meaning that kerning is disabled. + public var kern: CGFloat { + get { + return textViewController.kern + } + set { + textViewController.kern = newValue + } + } + /// The text view shows a page guide when enabled. Use `pageGuideColumn` to specify the location of the page guide. + public var showPageGuide: Bool { + get { + return textViewController.showPageGuide + } + set { + textViewController.showPageGuide = newValue + } + } + /// Specifies the location of the page guide. Use `showPageGuide` to specify if the page guide should be shown. + public var pageGuideColumn: Int { + get { + return textViewController.pageGuideColumn + } + set { + textViewController.pageGuideColumn = newValue + } + } + /// Automatically scrolls the text view to show the caret when typing or moving the caret. + public var isAutomaticScrollEnabled: Bool { + get { + return textViewController.isAutomaticScrollEnabled + } + set { + textViewController.isAutomaticScrollEnabled = newValue + } + } + /// Amount of overscroll to add in the vertical direction. + /// + /// The overscroll is a factor of the scrollable area height and will not take into account any insets. 0 means no overscroll and 1 means an amount equal to the height of the text view. Detaults to 0. + public var verticalOverscrollFactor: CGFloat { + get { + return textViewController.verticalOverscrollFactor + } + set { + textViewController.verticalOverscrollFactor = newValue + } + } + /// Amount of overscroll to add in the horizontal direction. + /// + /// The overscroll is a factor of the scrollable area height and will not take into account any insets or the width of the gutter. 0 means no overscroll and 1 means an amount equal to the width of the text view. Detaults to 0. + public var horizontalOverscrollFactor: CGFloat { + get { + return textViewController.horizontalOverscrollFactor + } + set { + textViewController.horizontalOverscrollFactor = newValue + } + } + /// 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? { + textViewController.lengthOfInitallyLongestLine + } + /// Ranges in the text to be highlighted. The color defined by the background will be drawen behind the text. + public var highlightedRanges: [HighlightedRange] { + get { + return textViewController.highlightedRanges + } + set { + textViewController.highlightedRanges = newValue + } + } + /// Wheter the text view should loop when navigating through highlighted ranges using `selectPreviousHighlightedRange` or `selectNextHighlightedRange` on the text view. + public var highlightedRangeLoopingMode: HighlightedRangeLoopingMode { + get { + return textViewController.highlightedRangeLoopingMode + } + set { + textViewController.highlightedRangeLoopingMode = newValue + } + } + /// Line endings to use when inserting a line break. + /// + /// The value only affects new line breaks inserted in the text view and changing this value does not change the line endings of the text in the text view. Defaults to Unix (LF). + /// + /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)`` and ``TextView/replaceText(in:)``. + public var lineEndings: LineEnding { + get { + return textViewController.lineEndings + } + set { + textViewController.lineEndings = newValue + } + } + + private(set) lazy var textViewController = TextViewController( + textView: self, + scrollView: scrollView, + scrollContentView: scrollContentView + ) + + private let scrollView = NSScrollView() + private let scrollContentView = FlippedView() + + public init() { + super.init(frame: .zero) + scrollView.borderType = .noBorder + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = true + scrollView.documentView = scrollContentView + scrollView.contentView.postsBoundsChangedNotifications = true + addSubview(scrollView) + setNeedsLayout() + subscribeToScrollViewBoundsDidChangeNotification() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + public override func resizeSubviews(withOldSize oldSize: NSSize) { + super.resizeSubviews(withOldSize: oldSize) + scrollView.frame = bounds + textViewController.scrollViewWidth = frame.width + textViewController.layoutIfNeeded() + textViewController.handleContentSizeUpdateIfNeeded() + textViewController.viewport = CGRect(origin: scrollView.contentOffset, size: frame.size) + } + + public override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + textViewController.performFullLayoutIfNeeded() + } + + public override func keyDown(with event: NSEvent) { + NSCursor.setHiddenUntilMouseMoves(true) + let didInputContextHandleEvent = inputContext?.handleEvent(event) ?? false + if !didInputContextHandleEvent { + super.keyDown(with: event) + } + } +} + +private extension TextView { + private func subscribeToScrollViewBoundsDidChangeNotification() { + NotificationCenter.default.addObserver( + self, + selector: #selector(scrollViewBoundsDidChange), + name: NSView.boundsDidChangeNotification, + object: scrollView.contentView + ) + } + + @objc private func scrollViewBoundsDidChange() { + textViewController.viewport = CGRect(origin: scrollView.contentOffset, size: frame.size) + textViewController.layoutIfNeeded() + print(textViewController.viewport) + } +} #endif diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 972221c83..eaa00720e 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -437,8 +437,12 @@ final class TextViewController { didSet { if showPageGuide != oldValue { if showPageGuide { + #if os(iOS) textView.addSubview(pageGuideController.guideView) textView.sendSubviewToBack(pageGuideController.guideView) + #else + textView.addSubview(pageGuideController.guideView, positioned: .below, relativeTo: nil) + #endif textView.setNeedsLayout() } else { pageGuideController.guideView.removeFromSuperview() From 08e28957ade8bdf241f98ea70ef4b27a437c7c53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 30 Jan 2023 17:21:37 +0100 Subject: [PATCH 048/232] Adds caret --- Sources/Runestone/Library/Caret.swift | 5 ++ .../TextView/Core/Mac/CaretView.swift | 59 +++++++++++++ .../TextView/Core/Mac/TextView_Mac.swift | 87 ++++++++++++++++++- .../TextViewController+Editing.swift | 2 +- .../TextViewController.swift | 11 +++ .../TextView/Core/iOS/TextView_iOS.swift | 16 ++-- 6 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 Sources/Runestone/TextView/Core/Mac/CaretView.swift diff --git a/Sources/Runestone/Library/Caret.swift b/Sources/Runestone/Library/Caret.swift index 8fde18870..600e44f22 100644 --- a/Sources/Runestone/Library/Caret.swift +++ b/Sources/Runestone/Library/Caret.swift @@ -1,7 +1,12 @@ import CoreGraphics enum Caret { + #if os(iOS) static let width: CGFloat = 2 + #else + static let width: CGFloat = 1 + #endif + static func defaultHeight(for font: MultiPlatformFont?) -> CGFloat { return font?.lineHeight ?? 15 diff --git a/Sources/Runestone/TextView/Core/Mac/CaretView.swift b/Sources/Runestone/TextView/Core/Mac/CaretView.swift new file mode 100644 index 000000000..fc640d598 --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/CaretView.swift @@ -0,0 +1,59 @@ +#if os(macOS) +import AppKit + +final class CaretView: NSView { + var color: NSColor = .white { + didSet { + if color != oldValue { + setNeedsDisplay() + } + } + } + + private var blinkTimer: Timer? + private var isVisible = true { + didSet { + if isVisible != oldValue { + setNeedsDisplay() + } + } + } + + var isBlinkingEnabled = false { + didSet { + if isBlinkingEnabled != oldValue { + blinkTimer?.invalidate() + if isBlinkingEnabled { + blinkTimer = .scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(blink), userInfo: nil, repeats: true) + } + } + } + } + + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard let context = NSGraphicsContext.current?.cgContext else { + return + } + context.clear(bounds) + if isVisible { + let rect = CGRect(origin: .zero, size: bounds.size) + context.setFillColor(color.cgColor) + context.fill(rect) + } + } + + func delayBlinkIfNeeded() { + let wasBlinking = isBlinkingEnabled + isBlinkingEnabled = false + isVisible = true + isBlinkingEnabled = wasBlinking + } +} + +private extension CaretView { + @objc private func blink() { + isVisible.toggle() + } +} +#endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index b3b1a8e88..f890cf8e0 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -344,17 +344,35 @@ public final class TextView: NSView { private let scrollView = NSScrollView() private let scrollContentView = FlippedView() + private let caretView = CaretView() + private var isWindowKey = false { + didSet { + if isWindowKey != oldValue { + updateCaretVisibility() + } + } + } + private var isFirstResponder = false { + didSet { + if isFirstResponder != oldValue { + updateCaretVisibility() + } + } + } public init() { super.init(frame: .zero) + textViewController.delegate = self scrollView.borderType = .noBorder scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = true scrollView.documentView = scrollContentView scrollView.contentView.postsBoundsChangedNotifications = true + scrollContentView.addSubview(caretView) addSubview(scrollView) setNeedsLayout() - subscribeToScrollViewBoundsDidChangeNotification() + setupWindowObservers() + setupScrollViewBoundsDidChangeObserver() } required init?(coder: NSCoder) { @@ -365,6 +383,22 @@ public final class TextView: NSView { NotificationCenter.default.removeObserver(self) } + public override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + if result { + isFirstResponder = true + } + return result + } + + public override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + if result { + isFirstResponder = false + } + return result + } + public override func resizeSubviews(withOldSize oldSize: NSSize) { super.resizeSubviews(withOldSize: oldSize) scrollView.frame = bounds @@ -372,6 +406,7 @@ public final class TextView: NSView { textViewController.layoutIfNeeded() textViewController.handleContentSizeUpdateIfNeeded() textViewController.viewport = CGRect(origin: scrollView.contentOffset, size: frame.size) + updateCaretFrame() } public override func viewDidMoveToWindow() { @@ -389,7 +424,26 @@ public final class TextView: NSView { } private extension TextView { - private func subscribeToScrollViewBoundsDidChangeNotification() { + private func setupWindowObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(windowKeyStateDidChange), + name: NSWindow.didBecomeKeyNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(windowKeyStateDidChange), + name: NSWindow.didResignKeyNotification, + object: nil + ) + } + + @objc private func windowKeyStateDidChange() { + isWindowKey = window?.isKeyWindow ?? false + } + + private func setupScrollViewBoundsDidChangeObserver() { NotificationCenter.default.addObserver( self, selector: #selector(scrollViewBoundsDidChange), @@ -403,5 +457,34 @@ private extension TextView { textViewController.layoutIfNeeded() print(textViewController.viewport) } + + private func updateCaretFrame() { + let selectedRange = selectedRange() + let caretRect = textViewController.caretRectService.caretRect(at: selectedRange.upperBound, allowMovingCaretToNextLineFragment: true) + caretView.frame = caretRect + } + + private func updateCaretVisibility() { + if isWindowKey && isFirstResponder { + caretView.isHidden = false + caretView.isBlinkingEnabled = true + } else { + caretView.isHidden = true + caretView.isBlinkingEnabled = false + } + } +} + +// MARK: - TextViewControllerDelegate +extension TextView: TextViewControllerDelegate { + func textViewControllerDidChangeText(_ textViewController: TextViewController) { + caretView.delayBlinkIfNeeded() + updateCaretFrame() + } + + func textViewController(_ textViewController: TextViewController, didUpdateSelectedRange selectedRange: NSRange?) { + caretView.delayBlinkIfNeeded() + updateCaretFrame() + } } #endif diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift index bebe699ed..af6adcd77 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift @@ -184,7 +184,7 @@ private extension TextViewController { if isAutomaticScrollEnabled, let newRange = selectedRange, newRange.length == 0 { scrollLocationToVisible(newRange.location) } - textView.editorDelegate?.textViewDidChange(textView) + delegate?.textViewControllerDidChangeText(self) } private func moveCaret(to linePosition: LinePosition) { diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index eaa00720e..62c85bab5 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -1,7 +1,17 @@ import Combine import Foundation +protocol TextViewControllerDelegate: AnyObject { + func textViewControllerDidChangeText(_ textViewController: TextViewController) + func textViewController(_ textViewController: TextViewController, didUpdateSelectedRange selectedRange: NSRange?) +} + +extension TextViewControllerDelegate { + func textViewController(_ textViewController: TextViewController, didUpdateSelectedRange selectedRange: NSRange?) {} +} + final class TextViewController { + weak var delegate: TextViewControllerDelegate? var textView: TextView { if let textView = _textView { return textView @@ -32,6 +42,7 @@ final class TextViewController { layoutManager.selectedRange = selectedRange layoutManager.setNeedsLayoutLineSelection() textView.setNeedsLayout() + delegate?.textViewController(self, didUpdateSelectedRange: selectedRange) } } } diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 912125247..5d4d2ec97 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -564,6 +564,7 @@ open class TextView: UIScrollView { /// - Parameter frame: The frame rectangle of the text view. public override init(frame: CGRect) { super.init(frame: frame) + textViewController.delegate = self backgroundColor = .white editableTextInteraction.textInput = self nonEditableTextInteraction.textInput = self @@ -1194,15 +1195,12 @@ private extension TextView { } } -//// MARK: - TextInputViewDelegate -//extension TextView: TextInputViewDelegate { -// func textInputViewDidChange(_ view: TextInputView) { -// if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { -// scrollLocationToVisible(newRange.location) -// } -// editorDelegate?.textViewDidChange(self) -// } -//} +// MARK: - TextViewControllerDelegate +extension TextView: TextViewControllerDelegate { + func textViewControllerDidChangeText(_ textViewController: TextViewController) { + editorDelegate?.textViewDidChange(self) + } +} // MARK: - SearchControllerDelegate extension TextView: SearchControllerDelegate { From 76187adabd3ab7439e142ee3f4c0924b75403c58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 30 Jan 2023 17:57:54 +0100 Subject: [PATCH 049/232] Ensures caret is instantly visible --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index f890cf8e0..d4636d04e 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -468,6 +468,7 @@ private extension TextView { if isWindowKey && isFirstResponder { caretView.isHidden = false caretView.isBlinkingEnabled = true + caretView.delayBlinkIfNeeded() } else { caretView.isHidden = true caretView.isBlinkingEnabled = false From 32e40afd284c950808abfa2042b63219e2c577d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 17:01:01 +0100 Subject: [PATCH 050/232] Caret supports light mode --- Sources/Runestone/TextView/Core/Mac/CaretView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/Mac/CaretView.swift b/Sources/Runestone/TextView/Core/Mac/CaretView.swift index fc640d598..1c54513e8 100644 --- a/Sources/Runestone/TextView/Core/Mac/CaretView.swift +++ b/Sources/Runestone/TextView/Core/Mac/CaretView.swift @@ -2,7 +2,7 @@ import AppKit final class CaretView: NSView { - var color: NSColor = .white { + var color: NSColor = .label { didSet { if color != oldValue { setNeedsDisplay() From d90aacb688103d7ab530e2b426dcbe687daf9d43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 17:25:14 +0100 Subject: [PATCH 051/232] Adds @discardableResult --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index d4636d04e..c76478729 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -383,6 +383,7 @@ public final class TextView: NSView { NotificationCenter.default.removeObserver(self) } + @discardableResult public override func becomeFirstResponder() -> Bool { let result = super.becomeFirstResponder() if result { @@ -391,6 +392,7 @@ public final class TextView: NSView { return result } + @discardableResult public override func resignFirstResponder() -> Bool { let result = super.resignFirstResponder() if result { From 7c712a8d3a8588924f41796df7ac264a6230e11a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 17:25:22 +0100 Subject: [PATCH 052/232] Sets initial selectedRange --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index c76478729..85a72bd4d 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -363,6 +363,7 @@ public final class TextView: NSView { public init() { super.init(frame: .zero) textViewController.delegate = self + textViewController.selectedRange = NSRange(location: 0, length: 0) scrollView.borderType = .noBorder scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = true From 26ebc9a22d596f7362ed2262676864bbe9cf3309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 17:25:34 +0100 Subject: [PATCH 053/232] Adds isEditing and text properties --- .../Runestone/TextView/Core/Mac/TextView_Mac.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 85a72bd4d..01166bf4c 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -6,6 +6,20 @@ public final class TextView: NSView { public override var acceptsFirstResponder: Bool { true } + /// Whether the text view is in a state where the contents can be edited. + public var isEditing: Bool { + textViewController.isEditing + } + /// The text that the text view displays. + public var text: String { + get { + return textViewController.text + } + set { + textViewController.text = newValue + scrollView.contentSize = textViewController.contentSize + } + } /// Character pairs are used by the editor to automatically insert a trailing character when the user types the leading character. /// /// Common usages of this includes the \" character to surround strings and { } to surround a scope. From 703bc73cf544ab4b3eb0eb378b24bafd59f6552c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 17:25:43 +0100 Subject: [PATCH 054/232] Fixes layout order --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 01166bf4c..268b70123 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -419,10 +419,10 @@ public final class TextView: NSView { public override func resizeSubviews(withOldSize oldSize: NSSize) { super.resizeSubviews(withOldSize: oldSize) scrollView.frame = bounds - textViewController.scrollViewWidth = frame.width + textViewController.viewport = CGRect(origin: scrollView.contentOffset, size: frame.size) + textViewController.scrollViewWidth = scrollView.frame.width textViewController.layoutIfNeeded() textViewController.handleContentSizeUpdateIfNeeded() - textViewController.viewport = CGRect(origin: scrollView.contentOffset, size: frame.size) updateCaretFrame() } From 875338b339289084f81d26548b485335b1509f80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 19:27:37 +0100 Subject: [PATCH 055/232] Introduces StringTokenizer --- .../TextView/Core/StringTokenizer.swift | 246 ++++++++++++++ .../Core/iOS/TextInputStringTokenizer.swift | 308 ++++-------------- 2 files changed, 318 insertions(+), 236 deletions(-) create mode 100644 Sources/Runestone/TextView/Core/StringTokenizer.swift diff --git a/Sources/Runestone/TextView/Core/StringTokenizer.swift b/Sources/Runestone/TextView/Core/StringTokenizer.swift new file mode 100644 index 000000000..3eed18049 --- /dev/null +++ b/Sources/Runestone/TextView/Core/StringTokenizer.swift @@ -0,0 +1,246 @@ +import Foundation + +final class StringTokenizer { + enum Granularity { + case line + case paragraph + case word + } + + enum Direction { + case forward + case backward + } + + var lineManager: LineManager + var stringView: StringView + + private let lineControllerStorage: LineControllerStorage + private var newlineCharacters: [Character] { + return [Symbol.Character.lineFeed, Symbol.Character.carriageReturn, Symbol.Character.carriageReturnLineFeed] + } + + init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { + self.lineManager = lineManager + self.stringView = stringView + self.lineControllerStorage = lineControllerStorage + } + + func isLocation(_ location: Int, atBoundary granularity: Granularity, inDirection direction: Direction) -> Bool { + switch granularity { + case .line: + return isLocation(location, atLineBoundaryInDirection: direction) + case .paragraph: + return isLocation(location, atParagraphBoundaryInDirection: direction) + case .word: + return isLocation(location, atWordBoundaryInDirection: direction) + } + } + + func location(from location: Int, toBoundary granularity: Granularity, inDirection direction: Direction) -> Int? { + switch granularity { + case .line: + return self.location(from: location, toLineBoundaryInDirection: direction) + case .paragraph: + return self.location(from: location, toParagraphBoundaryInDirection: direction) + case .word: + return self.location(from: location, toWordBoundaryInDirection: direction) + } + } +} + +// MARK: - Lines +private extension StringTokenizer { + private func isLocation(_ location: Int, atLineBoundaryInDirection direction: Direction) -> Bool { + guard let line = lineManager.line(containingCharacterAt: location) else { + return false + } + let lineLocation = line.location + let lineLocalLocation = location - lineLocation + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + guard lineLocalLocation >= 0 && lineLocalLocation <= line.data.totalLength else { + return false + } + guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { + return false + } + if direction == .forward { + let isLastLineFragment = lineFragmentNode.index == lineController.numberOfLineFragments - 1 + if isLastLineFragment { + return location == lineLocation + lineFragmentNode.location + lineFragmentNode.value - line.data.delimiterLength + } else { + return location == lineLocation + lineFragmentNode.location + lineFragmentNode.value + } + } else { + return location == lineLocation + lineFragmentNode.location + } + } + + private func location(from location: Int, toLineBoundaryInDirection direction: Direction) -> Int? { + guard let line = lineManager.line(containingCharacterAt: location) else { + return nil + } + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + let lineLocation = line.location + let lineLocalLocation = location - lineLocation + guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { + return nil + } + if direction == .forward { + if location == stringView.string.length { + return location + } else { + let lineFragmentRangeUpperBound = lineFragmentNode.location + lineFragmentNode.value + let preferredLocation = lineLocation + lineFragmentRangeUpperBound + let lineEndLocation = lineLocation + line.data.totalLength + if preferredLocation == lineEndLocation { + // Navigate to end of line but before the delimiter (\n etc.) + return 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: lineFragmentRangeUpperBound) + return lineLocation + lineFragmentRangeUpperBound - lastCharacterRange.length + } + } + } else if location == 0 { + return location + } else { + return lineLocation + lineFragmentNode.location + } + } +} + +// MARK: - Paragraphs +private extension StringTokenizer { + private func isLocation(_ location: Int, atParagraphBoundaryInDirection direction: Direction) -> 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 location(from location: Int, toParagraphBoundaryInDirection direction: Direction) -> Int? { + if direction == .forward { + if location == stringView.string.length { + return location + } 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 currentIndex + } + } else { + if location == 0 { + return location + } 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 currentIndex + } + } + } +} + +// MARK: - Words +private extension StringTokenizer { + private func isLocation(_ location: Int, atWordBoundaryInDirection direction: Direction) -> Bool { + let alphanumerics: CharacterSet = .alphanumerics + if direction == .forward { + 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 + } + } + } + + // swiftlint:disable:next cyclomatic_complexity + private func location(from location: Int, toWordBoundaryInDirection direction: Direction) -> Int? { + let alphanumerics: CharacterSet = .alphanumerics + if direction == .forward { + if location == stringView.string.length { + return location + } 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 currentIndex + } else { + return nil + } + } else { + if location == 0 { + return location + } 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 currentIndex + } else { + return nil + } + } + } +} + +private extension CharacterSet { + func contains(_ character: Character) -> Bool { + return character.unicodeScalars.allSatisfy(contains(_:)) + } +} diff --git a/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift index 7377bafc6..6ea6ab9c2 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift @@ -2,266 +2,108 @@ import UIKit final class TextInputStringTokenizer: UITextInputStringTokenizer { - var lineManager: LineManager - var stringView: StringView - - 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 - self.stringView = stringView - self.lineControllerStorage = lineControllerStorage - super.init(textInput: textInput) - } - - override func isPosition(_ position: UITextPosition, atBoundary granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool { - if granularity == .line { - return isPosition(position, atLineBoundaryInDirection: direction) - } else if granularity == .paragraph { - return isPosition(position, atParagraphBoundaryInDirection: direction) - } else if granularity == .word { - return isPosition(position, atWordBoundaryInDirection: direction) - } else { - return super.isPosition(position, atBoundary: granularity, inDirection: direction) + var lineManager: LineManager { + get { + return stringTokenizer.lineManager + } + set { + stringTokenizer.lineManager = newValue } } - - 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? { - if granularity == .line { - return self.position(from: position, toLineBoundaryInDirection: direction) - } else if granularity == .paragraph { - 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) + var stringView: StringView { + get { + return stringTokenizer.stringView + } + set { + stringTokenizer.stringView = newValue } } - override func rangeEnclosingPosition(_ position: UITextPosition, - with granularity: UITextGranularity, - inDirection direction: UITextDirection) -> UITextRange? { - return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) + private let stringTokenizer: StringTokenizer + + init( + textInput: UIResponder & UITextInput, + stringView: StringView, + lineManager: LineManager, + lineControllerStorage: LineControllerStorage + ) { + self.stringTokenizer = StringTokenizer(stringView: stringView, lineManager: lineManager, lineControllerStorage: lineControllerStorage) + super.init(textInput: textInput) } -} -// MARK: - Lines -private extension TextInputStringTokenizer { - private func isPosition(_ position: UITextPosition, atLineBoundaryInDirection direction: UITextDirection) -> Bool { + override func isPosition( + _ position: UITextPosition, + atBoundary granularity: UITextGranularity, + inDirection direction: UITextDirection + ) -> Bool { guard let indexedPosition = position as? IndexedPosition else { return false } - let location = indexedPosition.index - guard let line = lineManager.line(containingCharacterAt: location) else { - return false - } - let lineLocation = line.location - let lineLocalLocation = location - lineLocation - let lineController = lineControllerStorage.getOrCreateLineController(for: line) - guard lineLocalLocation >= 0 && lineLocalLocation <= line.data.totalLength else { - return false - } - guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { - return false - } - if direction.isForward { - let isLastLineFragment = lineFragmentNode.index == lineController.numberOfLineFragments - 1 - if isLastLineFragment { - return location == lineLocation + lineFragmentNode.location + lineFragmentNode.value - line.data.delimiterLength - } else { - return location == lineLocation + lineFragmentNode.location + lineFragmentNode.value - } - } else { - return location == lineLocation + lineFragmentNode.location + guard let granularity = StringTokenizer.Granularity(granularity) else { + return super.isPosition(position, atBoundary: granularity, inDirection: direction) } + let direction = StringTokenizer.Direction(direction) + return stringTokenizer.isLocation(indexedPosition.index, atBoundary: granularity, inDirection: direction) } - private func position(from position: UITextPosition, toLineBoundaryInDirection direction: UITextDirection) -> UITextPosition? { + 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 nil } - let location = indexedPosition.index - guard let line = lineManager.line(containingCharacterAt: location) else { - return nil + guard let granularity = StringTokenizer.Granularity(granularity) else { + return super.position(from: position, toBoundary: granularity, inDirection: direction) } - let lineController = lineControllerStorage.getOrCreateLineController(for: line) - let lineLocation = line.location - let lineLocalLocation = location - lineLocation - guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { + let direction = StringTokenizer.Direction(direction) + guard let location = stringTokenizer.location(from: indexedPosition.index, toBoundary: granularity, inDirection: direction) else { return nil } - if direction.isForward { - if location == stringView.string.length { - return position - } else { - let lineFragmentRangeUpperBound = lineFragmentNode.location + lineFragmentNode.value - let preferredLocation = lineLocation + lineFragmentRangeUpperBound - 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: lineFragmentRangeUpperBound) - return IndexedPosition(index: lineLocation + lineFragmentRangeUpperBound - lastCharacterRange.length) - } - } - } else if location == 0 { - return position - } else { - return IndexedPosition(index: lineLocation + lineFragmentNode.location) - } + return IndexedPosition(index: location) } -} -// 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 + override func rangeEnclosingPosition( + _ position: UITextPosition, + with granularity: UITextGranularity, + inDirection direction: UITextDirection + ) -> UITextRange? { + return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) } +} - private func position(from position: UITextPosition, toParagraphBoundaryInDirection direction: UITextDirection) -> UITextPosition? { - guard let indexedPosition = position as? IndexedPosition else { +private extension StringTokenizer.Granularity { + init?(_ granularity: UITextGranularity) { + switch granularity { + case .word: + self = .word + case .paragraph: + self = .paragraph + case .line: + self = .line + case .character, .document, .sentence: + return nil + @unknown default: return nil - } - 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 { - 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) - } } } } -// MARK: - Words -private extension TextInputStringTokenizer { - private func isPosition(_ position: UITextPosition, atWordBoundaryInDirection direction: UITextDirection) -> Bool { - guard let indexedPosition = position as? IndexedPosition else { - return false - } - 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 - } - } - } - - // swiftlint:disable:next cyclomatic_complexity - 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 +private extension StringTokenizer.Direction { + init(_ direction: UITextDirection) { 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 - } + self = .forward } 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 - } + self = .backward } } } @@ -273,10 +115,4 @@ private extension UITextDirection { || rawValue == UITextLayoutDirection.down.rawValue } } - -private extension CharacterSet { - func contains(_ character: Character) -> Bool { - return character.unicodeScalars.allSatisfy(contains(_:)) - } -} #endif From 87b30d904ad707df76bc91f7b650e812cd6569d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 19:31:22 +0100 Subject: [PATCH 056/232] Supports navigation with the keyboard --- ...ller.swift => LineNavigationService.swift} | 122 +++++------------- .../Mac/TextView_Mac+NSTextInputClient.swift | 44 +------ .../TextView/Core/Mac/TextView_Mac.swift | 58 +++++++++ .../TextView/Core/NavigationService.swift | 89 +++++++++++++ .../TextViewController+Navigation.swift | 29 +++++ .../TextViewController.swift | 16 +-- .../Core/iOS/TextView_iOS+UITextInput.swift | 22 +++- .../TextView/Core/iOS/TextView_iOS.swift | 18 +-- 8 files changed, 242 insertions(+), 156 deletions(-) rename Sources/Runestone/TextView/Core/{iOS/LineMovementController.swift => LineNavigationService.swift} (51%) create mode 100644 Sources/Runestone/TextView/Core/NavigationService.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift diff --git a/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift b/Sources/Runestone/TextView/Core/LineNavigationService.swift similarity index 51% rename from Sources/Runestone/TextView/Core/iOS/LineMovementController.swift rename to Sources/Runestone/TextView/Core/LineNavigationService.swift index 63d84b6df..66a7c0901 100644 --- a/Sources/Runestone/TextView/Core/iOS/LineMovementController.swift +++ b/Sources/Runestone/TextView/Core/LineNavigationService.swift @@ -1,81 +1,36 @@ import Foundation -#if os(iOS) -import UIKit -#endif - -final class LineMovementController { - enum Direction { - case left - case right - case up - case down - } +final class LineNavigationService { var lineManager: LineManager - var stringView: StringView - let lineControllerStorage: LineControllerStorage + var lineControllerStorage: LineControllerStorage - init(lineManager: LineManager, stringView: StringView, lineControllerStorage: LineControllerStorage) { + init(lineManager: LineManager, lineControllerStorage: LineControllerStorage) { self.lineManager = lineManager - self.stringView = stringView self.lineControllerStorage = lineControllerStorage } - func location(from location: Int, in direction: Direction, offset: Int) -> Int? { - let newLocation: Int - switch direction { - case .left: - newLocation = locationForMoving(fromLocation: location, by: offset * -1) - case .right: - newLocation = locationForMoving(fromLocation: location, by: offset) - case .up: - newLocation = locationForMoving(lineOffset: offset * -1, fromLineContainingCharacterAt: location) - case .down: - newLocation = locationForMoving(lineOffset: offset, fromLineContainingCharacterAt: location) - } - if newLocation >= 0 && newLocation <= stringView.string.length { - return newLocation - } else { - return nil - } - } -} - -private extension LineMovementController { - private func locationForMoving(fromLocation location: Int, by offset: Int) -> Int { - let naiveNewLocation = location + offset - guard naiveNewLocation >= 0 && naiveNewLocation <= stringView.string.length else { - return location - } - guard naiveNewLocation > 0 && naiveNewLocation < stringView.string.length else { - return naiveNewLocation - } - let range = stringView.string.customRangeOfComposedCharacterSequence(at: naiveNewLocation) - guard naiveNewLocation > range.location && naiveNewLocation < range.location + range.length else { - return naiveNewLocation - } - if offset < 0 { - return location - range.length - } else { - return location + range.length - } - } - - private func locationForMoving(lineOffset: Int, fromLineContainingCharacterAt location: Int) -> Int { - guard let line = lineManager.line(containingCharacterAt: location) else { - return location + func location(movingFrom sourceLocation: Int, byOffset offset: Int) -> Int { + guard let line = lineManager.line(containingCharacterAt: sourceLocation) else { + return sourceLocation } guard let lineController = lineControllerStorage[line.id] else { - return location + return sourceLocation } - let lineLocalLocation = max(min(location - line.location, line.data.totalLength), 0) + let lineLocalLocation = max(min(sourceLocation - line.location, line.data.totalLength), 0) guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { - return location + return sourceLocation } let lineFragmentLocalLocation = lineLocalLocation - lineFragmentNode.location - return locationForMoving(lineOffset: lineOffset, fromLocation: lineFragmentLocalLocation, inLineFragmentAt: lineFragmentNode.index, of: line) + return locationForMoving( + lineOffset: offset, + fromLocation: lineFragmentLocalLocation, + inLineFragmentAt: lineFragmentNode.index, + of: line + ) } +} +private extension LineNavigationService { private func locationForMoving( lineOffset: Int, fromLocation location: Int, @@ -83,11 +38,20 @@ private extension LineMovementController { of line: DocumentLineNode ) -> Int { if lineOffset < 0 { - return locationForMovingUpwards(lineOffset: abs(lineOffset), fromLocation: location, inLineFragmentAt: lineFragmentIndex, of: line) + return locationForMovingUpwards( + lineOffset: abs(lineOffset), + fromLocation: location, + inLineFragmentAt: lineFragmentIndex, of: line + ) } else if lineOffset > 0 { - return locationForMovingDownwards(lineOffset: lineOffset, fromLocation: location, inLineFragmentAt: lineFragmentIndex, of: line) + return locationForMovingDownwards( + lineOffset: lineOffset, + fromLocation: location, + inLineFragmentAt: lineFragmentIndex, + of: line + ) } else { - // lineOffset is 0 so we shouldn't change the line + // lineOffset is 0 so we should not change the line. let lineController = lineControllerStorage.getOrCreateLineController(for: line) let destinationLineFragmentNode = lineController.lineFragmentNode(atIndex: lineFragmentIndex) let lineLocation = line.location @@ -144,30 +108,14 @@ private extension LineMovementController { return line.location + line.data.totalLength } let nextLine = lineManager.line(atRow: lineIndex + 1) - return locationForMovingDownwards(lineOffset: remainingLineOffset - 1, fromLocation: location, inLineFragmentAt: 0, of: nextLine) + 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 - } -} - -#if os(iOS) -extension LineMovementController.Direction { - init(_ direction: UITextLayoutDirection) { - switch direction { - case .right: - self = .right - case .left: - self = .left - case .up: - self = .up - case .down: - self = .down - @unknown default: - self = .down - } + lineControllerStorage.getOrCreateLineController(for: line).numberOfLineFragments } } -#endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift index 816f8686c..03536ee69 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift @@ -3,16 +3,10 @@ import AppKit extension TextView: NSTextInputClient { public override func doCommand(by selector: Selector) { - if selector == NSSelectorFromString("deleteBackward:") { - deleteBackward() - } else if selector == NSSelectorFromString("insertNewline:") { - textViewController.indentController.insertLineBreak(in: textViewController.rangeForInsertingText, using: lineEndings) - } else { - #if DEBUG - print(NSStringFromSelector(selector)) - #endif - super.doCommand(by: selector) - } + #if DEBUG + print(NSStringFromSelector(selector)) + #endif + super.doCommand(by: selector) } public func insertText(_ string: Any, replacementRange: NSRange) { @@ -58,34 +52,4 @@ extension TextView: NSTextInputClient { 0 } } - -private extension TextView { - private func deleteBackward() { - guard var selectedRange = textViewController.markedRange ?? textViewController.selectedRange else { - return - } - if selectedRange.length == 0 { - selectedRange.location -= 1 - selectedRange.length = 1 - } - let deleteRange = textViewController.rangeForDeletingText(in: selectedRange) - // If we're deleting everything in the marked range then we clear the marked range. UITextInput doesn't do that for us. - // Can be tested by entering a backtick (`) in an empty document and deleting it. - if deleteRange == textViewController.markedRange { - textViewController.markedRange = nil - } - guard textViewController.shouldChangeText(in: deleteRange, replacementText: "") else { - return - } - let isDeletingMultipleCharacters = selectedRange.length > 1 - if isDeletingMultipleCharacters { - undoManager?.endUndoGrouping() - undoManager?.beginUndoGrouping() - } - textViewController.replaceText(in: deleteRange, with: "", selectedRangeAfterUndo: selectedRange) - if isDeletingMultipleCharacters { - undoManager?.endUndoGrouping() - } - } -} #endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 268b70123..d5193e1d6 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -440,6 +440,58 @@ public final class TextView: NSView { } } +// MARK: - Commands +public extension TextView { + override func deleteBackward(_ sender: Any?) { + guard var selectedRange = textViewController.markedRange ?? textViewController.selectedRange else { + return + } + if selectedRange.length == 0 { + selectedRange.location -= 1 + selectedRange.length = 1 + } + let deleteRange = textViewController.rangeForDeletingText(in: selectedRange) + // If we're deleting everything in the marked range then we clear the marked range. UITextInput doesn't do that for us. + // Can be tested by entering a backtick (`) in an empty document and deleting it. + if deleteRange == textViewController.markedRange { + textViewController.markedRange = nil + } + guard textViewController.shouldChangeText(in: deleteRange, replacementText: "") else { + return + } + let isDeletingMultipleCharacters = selectedRange.length > 1 + if isDeletingMultipleCharacters { + undoManager?.endUndoGrouping() + undoManager?.beginUndoGrouping() + } + textViewController.replaceText(in: deleteRange, with: "", selectedRangeAfterUndo: selectedRange) + if isDeletingMultipleCharacters { + undoManager?.endUndoGrouping() + } + } + + override func insertNewline(_ sender: Any?) { + textViewController.indentController.insertLineBreak(in: textViewController.rangeForInsertingText, using: lineEndings) + } + + override func moveLeft(_ sender: Any?) { + textViewController.moveLeft() + } + + override func moveRight(_ sender: Any?) { + textViewController.moveRight() + } + + override func moveUp(_ sender: Any?) { + textViewController.moveUp() + } + + override func moveDown(_ sender: Any?) { + textViewController.moveDown() + } +} + +// MARK: - Window private extension TextView { private func setupWindowObservers() { NotificationCenter.default.addObserver( @@ -459,7 +511,10 @@ private extension TextView { @objc private func windowKeyStateDidChange() { isWindowKey = window?.isKeyWindow ?? false } +} +// MARK: - Scroll Bounds +private extension TextView { private func setupScrollViewBoundsDidChangeObserver() { NotificationCenter.default.addObserver( self, @@ -474,7 +529,10 @@ private extension TextView { textViewController.layoutIfNeeded() print(textViewController.viewport) } +} +// MARK: - Caret +private extension TextView { private func updateCaretFrame() { let selectedRange = selectedRange() let caretRect = textViewController.caretRectService.caretRect(at: selectedRange.upperBound, allowMovingCaretToNextLineFragment: true) diff --git a/Sources/Runestone/TextView/Core/NavigationService.swift b/Sources/Runestone/TextView/Core/NavigationService.swift new file mode 100644 index 000000000..6bf20e2aa --- /dev/null +++ b/Sources/Runestone/TextView/Core/NavigationService.swift @@ -0,0 +1,89 @@ +import Foundation + +final class NavigationService { + enum Granularity { + case character + case line + } + + var stringView: StringView + var lineManager: LineManager { + get { + return lineNavigationService.lineManager + } + set { + lineNavigationService.lineManager = newValue + } + } + var lineControllerStorage: LineControllerStorage { + get { + return lineNavigationService.lineControllerStorage + } + set { + lineNavigationService.lineControllerStorage = newValue + } + } + + private struct LineMovementOperation { + let sourceLocation: Int + let offset: Int + } + + private let lineNavigationService: LineNavigationService + private var previousLineMovementOperation: LineMovementOperation? + + init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { + self.stringView = stringView + self.lineNavigationService = LineNavigationService( + lineManager: lineManager, + lineControllerStorage: lineControllerStorage + ) + } + + func location(movingFrom sourceLocation: Int, by granularity: Granularity, offset: Int) -> Int { + switch granularity { + case .character: + previousLineMovementOperation = nil + return location(movingFrom: sourceLocation, byCharacterCount: offset) + case .line: + #if os(iOS) + return lineNavigationService.location(movingFrom: sourceLocation, byOffset: offset) + #else + if let previousLineMovementOperation { + let newOffset = previousLineMovementOperation.offset + offset + let overridenSourceLocation = previousLineMovementOperation.sourceLocation + self.previousLineMovementOperation = LineMovementOperation(sourceLocation: overridenSourceLocation, offset: newOffset) + return lineNavigationService.location(movingFrom: overridenSourceLocation, byOffset: newOffset) + } else { + previousLineMovementOperation = LineMovementOperation(sourceLocation: sourceLocation, offset: offset) + return lineNavigationService.location(movingFrom: sourceLocation, byOffset: offset) + } + #endif + } + } + + func resetPreviousLineMovementOperation() { + previousLineMovementOperation = nil + } +} + +private extension NavigationService { + private func location(movingFrom sourceLocation: Int, byCharacterCount offset: Int) -> Int { + let naiveNewLocation = sourceLocation + offset + guard naiveNewLocation >= 0 && naiveNewLocation <= stringView.string.length else { + return sourceLocation + } + guard naiveNewLocation > 0 && naiveNewLocation < stringView.string.length else { + return naiveNewLocation + } + let range = stringView.string.customRangeOfComposedCharacterSequence(at: naiveNewLocation) + guard naiveNewLocation > range.location && naiveNewLocation < range.location + range.length else { + return naiveNewLocation + } + if offset < 0 { + return sourceLocation - range.length + } else { + return sourceLocation + range.length + } + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift new file mode 100644 index 000000000..805fa62c2 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift @@ -0,0 +1,29 @@ +import Foundation + +extension TextViewController { + func moveLeft() { + move(by: .character, offset: -1) + } + + func moveRight() { + move(by: .character, offset: 1) + } + + func moveUp() { + move(by: .line, offset: -1) + } + + func moveDown() { + move(by: .line, offset: 1) + } +} + +private extension TextViewController { + private func move(by granularity: NavigationService.Granularity, offset: Int) { + guard let sourceLocation = selectedRange?.location else { + return + } + let destinationLocation = navigationService.location(movingFrom: sourceLocation, by: granularity, offset: offset) + selectedRange = NSRange(location: destinationLocation, length: 0) + } +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 62c85bab5..f4fc80d8d 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -147,7 +147,7 @@ final class TextViewController { lineControllerStorage.stringView = stringView layoutManager.stringView = stringView indentController.stringView = stringView - lineMovementController.stringView = stringView + navigationService.stringView = stringView } } } @@ -156,11 +156,11 @@ final class TextViewController { didSet { if lineManager !== oldValue { indentController.lineManager = lineManager - lineMovementController.lineManager = lineManager gutterWidthService.lineManager = lineManager contentSizeService.lineManager = lineManager caretRectService.lineManager = lineManager selectionRectService.lineManager = lineManager + navigationService.lineManager = lineManager highlightService.lineManager = lineManager } } @@ -172,9 +172,9 @@ final class TextViewController { let contentSizeService: ContentSizeService let caretRectService: CaretRectService let selectionRectService: SelectionRectService + let navigationService: NavigationService let layoutManager: LayoutManager let indentController: IndentController - let lineMovementController: LineMovementController let pageGuideController = PageGuideController() let highlightNavigationController = HighlightNavigationController() let timedUndoManager = TimedUndoManager() @@ -564,6 +564,11 @@ final class TextViewController { gutterWidthService: gutterWidthService, caretRectService: caretRectService ) + navigationService = NavigationService( + stringView: stringView, + lineManager: lineManager, + lineControllerStorage: lineControllerStorage + ) layoutManager = LayoutManager( lineManager: lineManager, languageMode: languageMode, @@ -583,11 +588,6 @@ final class TextViewController { indentStrategy: indentStrategy, indentFont: theme.font ) - lineMovementController = LineMovementController( - lineManager: lineManager, - stringView: stringView, - lineControllerStorage: lineControllerStorage - ) layoutManager.delegate = self layoutManager.containerView = scrollContentView applyThemeToChildren() diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index 68e20578f..f1c465f11 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -284,15 +284,23 @@ public extension TextView { return nil } didCallPositionFromPositionInDirectionWithOffset = true - let direction = LineMovementController.Direction(direction) - guard let newLocation = textViewController.lineMovementController.location( - from: indexedPosition.index, - in: direction, - offset: offset - ) else { + let navigationService = textViewController.navigationService + switch direction { + case .right: + let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: .character, offset: offset) + return IndexedPosition(index: newLocation) + case .left: + let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: .character, offset: offset * -1) + return IndexedPosition(index: newLocation) + case .up: + let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: .line, offset: offset * -1) + return IndexedPosition(index: newLocation) + case .down: + let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: .line, offset: offset) + return IndexedPosition(index: newLocation) + @unknown default: return nil } - return IndexedPosition(index: newLocation) } func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 5d4d2ec97..0f33b17c4 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -1166,16 +1166,16 @@ private extension TextView { } switch keyCode { case .keyboardUpArrow: - moveCaret(.up) + textViewController.moveUp() unmarkText() case .keyboardRightArrow: - moveCaret(.right) + textViewController.moveRight() unmarkText() case .keyboardDownArrow: - moveCaret(.down) + textViewController.moveDown() unmarkText() case .keyboardLeftArrow: - moveCaret(.left) + textViewController.moveLeft() unmarkText() case .keyboardEscape: unmarkText() @@ -1183,16 +1183,6 @@ private extension TextView { break } } - - private func moveCaret(_ direction: LineMovementController.Direction) { - guard let selectedRange = textViewController.selectedRange else { - return - } - guard let location = textViewController.lineMovementController.location(from: selectedRange.location, in: direction, offset: 1) else { - return - } - self.selectedRange = NSRange(location: location, length: 0) - } } // MARK: - TextViewControllerDelegate From 0fae00553f83c33f3ee220a8299561b2e7078da0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 20:26:52 +0100 Subject: [PATCH 057/232] Removes return statement --- Sources/Runestone/TextView/Core/StringTokenizer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/StringTokenizer.swift b/Sources/Runestone/TextView/Core/StringTokenizer.swift index 3eed18049..9221b74d6 100644 --- a/Sources/Runestone/TextView/Core/StringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/StringTokenizer.swift @@ -17,7 +17,7 @@ final class StringTokenizer { private let lineControllerStorage: LineControllerStorage private var newlineCharacters: [Character] { - return [Symbol.Character.lineFeed, Symbol.Character.carriageReturn, Symbol.Character.carriageReturnLineFeed] + [Symbol.Character.lineFeed, Symbol.Character.carriageReturn, Symbol.Character.carriageReturnLineFeed] } init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { From ddbfd2e841ebe1c4f4b1537771802ac69be40de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 20:28:13 +0100 Subject: [PATCH 058/232] Fixes navigating between words followed by emojis --- .../TextView/Core/StringTokenizer.swift | 67 +++++++++++++------ .../Runestone/TextView/Core/StringView.swift | 8 --- 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/Sources/Runestone/TextView/Core/StringTokenizer.swift b/Sources/Runestone/TextView/Core/StringTokenizer.swift index 9221b74d6..a9059d98b 100644 --- a/Sources/Runestone/TextView/Core/StringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/StringTokenizer.swift @@ -125,10 +125,10 @@ private extension StringTokenizer { } else { var currentIndex = location while currentIndex < stringView.string.length { - guard let currentCharacter = stringView.character(at: currentIndex) else { + guard let currentString = stringView.composedSubstring(at: currentIndex) else { break } - if newlineCharacters.contains(currentCharacter) { + if currentString.count == 1, let character = currentString.first, newlineCharacters.contains(character) { break } currentIndex += 1 @@ -141,10 +141,10 @@ private extension StringTokenizer { } else { var currentIndex = location - 1 while currentIndex > 0 { - guard let currentCharacter = stringView.character(at: currentIndex) else { + guard let currentString = stringView.composedSubstring(at: currentIndex) else { break } - if newlineCharacters.contains(currentCharacter) { + if currentString.count == 1, let character = currentString.first, newlineCharacters.contains(character) { currentIndex += 1 break } @@ -163,11 +163,11 @@ private extension StringTokenizer { if direction == .forward { if location == 0 { return false - } else if let previousCharacter = stringView.character(at: location - 1) { + } else if let previousCharacter = stringView.composedSubstring(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) + return alphanumerics.containsAllCharacters(of: previousCharacter) + } else if let character = stringView.composedSubstring(at: location) { + return alphanumerics.containsAllCharacters(of: previousCharacter) && !alphanumerics.containsAllCharacters(of: character) } else { return false } @@ -177,11 +177,11 @@ private extension StringTokenizer { } else { if location == stringView.string.length { return false - } else if let character = stringView.character(at: location) { + } else if let string = stringView.composedSubstring(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) + return alphanumerics.containsAllCharacters(of: string) + } else if let previousCharacter = stringView.composedSubstring(at: location - 1) { + return alphanumerics.containsAllCharacters(of: string) && !alphanumerics.containsAllCharacters(of: previousCharacter) } else { return false } @@ -197,15 +197,15 @@ private extension StringTokenizer { if direction == .forward { if location == stringView.string.length { return location - } else if let referenceCharacter = stringView.character(at: location) { - let isReferenceCharacterAlphanumeric = alphanumerics.contains(referenceCharacter) + } else if let referenceString = stringView.composedSubstring(at: location) { + let isReferenceStringAlphanumeric = alphanumerics.containsAllCharacters(of: referenceString) var currentIndex = location + 1 while currentIndex < stringView.string.length { - guard let currentCharacter = stringView.character(at: currentIndex) else { + guard let currentString = stringView.composedSubstring(at: currentIndex) else { break } - let isCurrentCharacterAlphanumeric = alphanumerics.contains(currentCharacter) - if isReferenceCharacterAlphanumeric != isCurrentCharacterAlphanumeric { + let isCurrentStringAlphanumeric = alphanumerics.containsAllCharacters(of: currentString) + if isReferenceStringAlphanumeric != isCurrentStringAlphanumeric { break } currentIndex += 1 @@ -217,15 +217,15 @@ private extension StringTokenizer { } else { if location == 0 { return location - } else if let referenceCharacter = stringView.character(at: location - 1) { - let isReferenceCharacterAlphanumeric = alphanumerics.contains(referenceCharacter) + } else if let referenceString = stringView.composedSubstring(at: location - 1) { + let isReferenceStringAlphanumeric = alphanumerics.containsAllCharacters(of: referenceString) var currentIndex = location - 1 while currentIndex > 0 { - guard let currentCharacter = stringView.character(at: currentIndex) else { + guard let currentString = stringView.composedSubstring(at: currentIndex) else { break } - let isCurrentCharacterAlphanumeric = alphanumerics.contains(currentCharacter) - if isReferenceCharacterAlphanumeric != isCurrentCharacterAlphanumeric { + let isCurrentStringAlphanumeric = alphanumerics.containsAllCharacters(of: currentString) + if isReferenceStringAlphanumeric != isCurrentStringAlphanumeric { currentIndex += 1 break } @@ -244,3 +244,26 @@ private extension CharacterSet { return character.unicodeScalars.allSatisfy(contains(_:)) } } + +private extension StringView { + func composedSubstring(at location: Int) -> String? { + guard location >= 0 && location < string.length else { + return nil + } + let range = string.customRangeOfComposedCharacterSequence(at: location) + return substring(in: range) + } +} + +private extension CharacterSet { + func containsAllCharacters(of string: String) -> Bool { + var containsAllCharacters = true + for char in string.unicodeScalars { + if !contains(char) { + containsAllCharacters = false + break + } + } + return containsAllCharacters + } +} diff --git a/Sources/Runestone/TextView/Core/StringView.swift b/Sources/Runestone/TextView/Core/StringView.swift index c494b1fa2..157e808f3 100644 --- a/Sources/Runestone/TextView/Core/StringView.swift +++ b/Sources/Runestone/TextView/Core/StringView.swift @@ -55,14 +55,6 @@ final class StringView { } } - func character(at location: Int) -> Character? { - if location >= 0 && location < string.length, let scalar = Unicode.Scalar(internalString.character(at: location)) { - return Character(scalar) - } else { - return nil - } - } - func replaceText(in range: NSRange, with string: String) { internalString.replaceCharacters(in: range, with: string) invalidate() From 4bb11427b32873c390fb7594fe85a0003f6ba3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 20:30:37 +0100 Subject: [PATCH 059/232] Supports jumping between words --- .../TextView/Core/Mac/TextView_Mac.swift | 8 ++ .../TextView/Core/NavigationService.swift | 78 +++++++++++++++---- .../TextViewController+Navigation.swift | 8 ++ 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index d5193e1d6..5eb5ed013 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -489,6 +489,14 @@ public extension TextView { override func moveDown(_ sender: Any?) { textViewController.moveDown() } + + override func moveWordLeft(_ sender: Any?) { + textViewController.moveWordLeft() + } + + override func moveWordRight(_ sender: Any?) { + textViewController.moveWordRight() + } } // MARK: - Window diff --git a/Sources/Runestone/TextView/Core/NavigationService.swift b/Sources/Runestone/TextView/Core/NavigationService.swift index 6bf20e2aa..333380b97 100644 --- a/Sources/Runestone/TextView/Core/NavigationService.swift +++ b/Sources/Runestone/TextView/Core/NavigationService.swift @@ -4,6 +4,7 @@ final class NavigationService { enum Granularity { case character case line + case word } var stringView: StringView @@ -30,14 +31,13 @@ final class NavigationService { } private let lineNavigationService: LineNavigationService + private let stringTokenizer: StringTokenizer private var previousLineMovementOperation: LineMovementOperation? init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { self.stringView = stringView - self.lineNavigationService = LineNavigationService( - lineManager: lineManager, - lineControllerStorage: lineControllerStorage - ) + self.lineNavigationService = LineNavigationService(lineManager: lineManager, lineControllerStorage: lineControllerStorage) + self.stringTokenizer = StringTokenizer(stringView: stringView, lineManager: lineManager, lineControllerStorage: lineControllerStorage) } func location(movingFrom sourceLocation: Int, by granularity: Granularity, offset: Int) -> Int { @@ -45,20 +45,11 @@ final class NavigationService { case .character: previousLineMovementOperation = nil return location(movingFrom: sourceLocation, byCharacterCount: offset) + case .word: + previousLineMovementOperation = nil + return location(movingFrom: sourceLocation, byWordCount: offset) case .line: - #if os(iOS) - return lineNavigationService.location(movingFrom: sourceLocation, byOffset: offset) - #else - if let previousLineMovementOperation { - let newOffset = previousLineMovementOperation.offset + offset - let overridenSourceLocation = previousLineMovementOperation.sourceLocation - self.previousLineMovementOperation = LineMovementOperation(sourceLocation: overridenSourceLocation, offset: newOffset) - return lineNavigationService.location(movingFrom: overridenSourceLocation, byOffset: newOffset) - } else { - previousLineMovementOperation = LineMovementOperation(sourceLocation: sourceLocation, offset: offset) - return lineNavigationService.location(movingFrom: sourceLocation, byOffset: offset) - } - #endif + return location(movingFrom: sourceLocation, byLineCount: offset) } } @@ -86,4 +77,57 @@ private extension NavigationService { return sourceLocation + range.length } } + + private func location(movingFrom sourceLocation: Int, byLineCount offset: Int) -> Int { + #if os(iOS) + return lineNavigationService.location(movingFrom: sourceLocation, byOffset: offset) + #else + // Attempts to simulate the behavior of UIKit and UITextInput. By using previousLineMovementOperation we can remember the local location within lines when navigating to shorter lines. + if let previousLineMovementOperation { + let newOffset = previousLineMovementOperation.offset + offset + let overridenSourceLocation = previousLineMovementOperation.sourceLocation + self.previousLineMovementOperation = LineMovementOperation(sourceLocation: overridenSourceLocation, offset: newOffset) + return lineNavigationService.location(movingFrom: overridenSourceLocation, byOffset: newOffset) + } else { + previousLineMovementOperation = LineMovementOperation(sourceLocation: sourceLocation, offset: offset) + return lineNavigationService.location(movingFrom: sourceLocation, byOffset: offset) + } + #endif + } + + private func location(movingFrom sourceLocation: Int, byWordCount offset: Int) -> Int { + // This attempts to reproduce the logic of UIKit and UITextInput calling an instance of UITextInputTokenizer. + let direction: StringTokenizer.Direction = offset > 0 ? .forward : .backward + var destinationLocation: Int? = sourceLocation + var remainingOffset = abs(offset) + // Run once for each word that we should offset. + while let newSourceLocation = destinationLocation, remainingOffset > 0 { + guard let tmpDestinationLocation = stringTokenizer.location( + from: newSourceLocation, + toBoundary: .word, + inDirection: direction + ) else { + destinationLocation = nil + continue + } + // If we end up at the boundary of a word then we run once more. + if stringTokenizer.isLocation(tmpDestinationLocation, atBoundary: .word, inDirection: direction.opposite) { + remainingOffset += 1 + } + destinationLocation = tmpDestinationLocation + remainingOffset -= 1 + } + return destinationLocation ?? sourceLocation + } +} + +private extension StringTokenizer.Direction { + var opposite: StringTokenizer.Direction { + switch self { + case .forward: + return .backward + case .backward: + return .forward + } + } } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift index 805fa62c2..4370dadfd 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift @@ -16,6 +16,14 @@ extension TextViewController { func moveDown() { move(by: .line, offset: 1) } + + func moveWordLeft() { + move(by: .word, offset: -1) + } + + func moveWordRight() { + move(by: .word, offset: 1) + } } private extension TextViewController { From b343fb2d09e1775fab4165d542719537fe45a376 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 21:01:03 +0100 Subject: [PATCH 060/232] Only stores new offset if destinationLocation is different from sourceLocation --- Sources/Runestone/TextView/Core/NavigationService.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Core/NavigationService.swift b/Sources/Runestone/TextView/Core/NavigationService.swift index 333380b97..75d81b87f 100644 --- a/Sources/Runestone/TextView/Core/NavigationService.swift +++ b/Sources/Runestone/TextView/Core/NavigationService.swift @@ -86,8 +86,13 @@ private extension NavigationService { if let previousLineMovementOperation { let newOffset = previousLineMovementOperation.offset + offset let overridenSourceLocation = previousLineMovementOperation.sourceLocation - self.previousLineMovementOperation = LineMovementOperation(sourceLocation: overridenSourceLocation, offset: newOffset) - return lineNavigationService.location(movingFrom: overridenSourceLocation, byOffset: newOffset) + let destinationLocation = lineNavigationService.location(movingFrom: overridenSourceLocation, byOffset: newOffset) + // Only store the updated offset if the destination location is different from the source location. + // Otherwise the user can jump to the end of the document multiple times by pressing the down key and will need to press the up key multiple times to jump back. + if destinationLocation != sourceLocation { + self.previousLineMovementOperation = LineMovementOperation(sourceLocation: overridenSourceLocation, offset: newOffset) + } + return destinationLocation } else { previousLineMovementOperation = LineMovementOperation(sourceLocation: sourceLocation, offset: offset) return lineNavigationService.location(movingFrom: sourceLocation, byOffset: offset) From 9adea564e3c19f7d38cfc122666ae9a68a24c492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 21:01:34 +0100 Subject: [PATCH 061/232] Supports jumping to line, paragraph, and document boundaries --- .../TextView/Core/Mac/TextView_Mac.swift | 32 +++++++++++++ .../TextView/Core/NavigationService.swift | 36 ++++++++++++++- .../TextView/Core/StringTokenizer.swift | 46 +++++++++++++++---- .../TextViewController+Navigation.swift | 42 +++++++++++++++++ 4 files changed, 144 insertions(+), 12 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 5eb5ed013..ed5d1bb6b 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -490,6 +490,14 @@ public extension TextView { textViewController.moveDown() } + override func moveForward(_ sender: Any?) { + textViewController.moveRight() + } + + override func moveBackward(_ sender: Any?) { + textViewController.moveLeft() + } + override func moveWordLeft(_ sender: Any?) { textViewController.moveWordLeft() } @@ -497,6 +505,30 @@ public extension TextView { override func moveWordRight(_ sender: Any?) { textViewController.moveWordRight() } + + override func moveToBeginningOfLine(_ sender: Any?) { + textViewController.moveToBeginningOfLine() + } + + override func moveToEndOfLine(_ sender: Any?) { + textViewController.moveToEndOfLine() + } + + override func moveToBeginningOfParagraph(_ sender: Any?) { + textViewController.moveToBeginningOfParagraph() + } + + override func moveToEndOfParagraph(_ sender: Any?) { + textViewController.moveToEndOfParagraph() + } + + override func moveToBeginningOfDocument(_ sender: Any?) { + textViewController.moveToBeginningOfDocument() + } + + override func moveToEndOfDocument(_ sender: Any?) { + textViewController.moveToEndOfDocument() + } } // MARK: - Window diff --git a/Sources/Runestone/TextView/Core/NavigationService.swift b/Sources/Runestone/TextView/Core/NavigationService.swift index 75d81b87f..3cdbca94a 100644 --- a/Sources/Runestone/TextView/Core/NavigationService.swift +++ b/Sources/Runestone/TextView/Core/NavigationService.swift @@ -7,6 +7,17 @@ final class NavigationService { case word } + enum Boundary { + case line + case paragraph + case document + } + + enum Direction { + case forward + case backward + } + var stringView: StringView var lineManager: LineManager { get { @@ -43,16 +54,28 @@ final class NavigationService { func location(movingFrom sourceLocation: Int, by granularity: Granularity, offset: Int) -> Int { switch granularity { case .character: - previousLineMovementOperation = nil return location(movingFrom: sourceLocation, byCharacterCount: offset) case .word: - previousLineMovementOperation = nil return location(movingFrom: sourceLocation, byWordCount: offset) case .line: return location(movingFrom: sourceLocation, byLineCount: offset) } } + func location(movingFrom sourceLocation: Int, toBoundary boundary: Boundary, inDirection direction: Direction) -> Int { + switch boundary { + case .line: + let mappedDirection = StringTokenizer.Direction(direction) + return stringTokenizer.location(from: sourceLocation, toBoundary: .line, inDirection: mappedDirection) ?? sourceLocation + case .paragraph: + let mappedDirection = StringTokenizer.Direction(direction) + return stringTokenizer.location(from: sourceLocation, toBoundary: .paragraph, inDirection: mappedDirection) ?? sourceLocation + case .document: + let mappedDirection = StringTokenizer.Direction(direction) + return stringTokenizer.location(from: sourceLocation, toBoundary: .document, inDirection: mappedDirection) ?? sourceLocation + } + } + func resetPreviousLineMovementOperation() { previousLineMovementOperation = nil } @@ -127,6 +150,15 @@ private extension NavigationService { } private extension StringTokenizer.Direction { + init(_ direction: NavigationService.Direction) { + switch direction { + case .forward: + self = .forward + case .backward: + self = .backward + } + } + var opposite: StringTokenizer.Direction { switch self { case .forward: diff --git a/Sources/Runestone/TextView/Core/StringTokenizer.swift b/Sources/Runestone/TextView/Core/StringTokenizer.swift index a9059d98b..0dd1e18dc 100644 --- a/Sources/Runestone/TextView/Core/StringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/StringTokenizer.swift @@ -1,10 +1,11 @@ import Foundation final class StringTokenizer { - enum Granularity { + enum Boundary { + case word case line case paragraph - case word + case document } enum Direction { @@ -26,25 +27,29 @@ final class StringTokenizer { self.lineControllerStorage = lineControllerStorage } - func isLocation(_ location: Int, atBoundary granularity: Granularity, inDirection direction: Direction) -> Bool { - switch granularity { + func isLocation(_ location: Int, atBoundary boundary: Boundary, inDirection direction: Direction) -> Bool { + switch boundary { + case .word: + return isLocation(location, atWordBoundaryInDirection: direction) case .line: return isLocation(location, atLineBoundaryInDirection: direction) case .paragraph: return isLocation(location, atParagraphBoundaryInDirection: direction) - case .word: - return isLocation(location, atWordBoundaryInDirection: direction) + case .document: + return isLocation(location, atDocumentBoundaryInDirection: direction) } } - func location(from location: Int, toBoundary granularity: Granularity, inDirection direction: Direction) -> Int? { - switch granularity { + func location(from location: Int, toBoundary boundary: Boundary, inDirection direction: Direction) -> Int? { + switch boundary { + case .word: + return self.location(from: location, toWordBoundaryInDirection: direction) case .line: return self.location(from: location, toLineBoundaryInDirection: direction) case .paragraph: return self.location(from: location, toParagraphBoundaryInDirection: direction) - case .word: - return self.location(from: location, toWordBoundaryInDirection: direction) + case .document: + return self.location(toDocumentBoundaryInDirection: direction) } } } @@ -239,6 +244,27 @@ private extension StringTokenizer { } } +// MARK: - Document +private extension StringTokenizer { + private func isLocation(_ location: Int, atDocumentBoundaryInDirection direction: Direction) -> Bool { + switch direction { + case .backward: + return location == 0 + case .forward: + return location == stringView.string.length + } + } + + private func location(toDocumentBoundaryInDirection direction: Direction) -> Int { + switch direction { + case .backward: + return 0 + case .forward: + return stringView.string.length + } + } +} + private extension CharacterSet { func contains(_ character: Character) -> Bool { return character.unicodeScalars.allSatisfy(contains(_:)) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift index 4370dadfd..39ae3f603 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift @@ -2,10 +2,12 @@ import Foundation extension TextViewController { func moveLeft() { + navigationService.resetPreviousLineMovementOperation() move(by: .character, offset: -1) } func moveRight() { + navigationService.resetPreviousLineMovementOperation() move(by: .character, offset: 1) } @@ -18,12 +20,44 @@ extension TextViewController { } func moveWordLeft() { + navigationService.resetPreviousLineMovementOperation() move(by: .word, offset: -1) } func moveWordRight() { + navigationService.resetPreviousLineMovementOperation() move(by: .word, offset: 1) } + + func moveToBeginningOfLine() { + navigationService.resetPreviousLineMovementOperation() + move(toBoundary: .line, inDirection: .backward) + } + + func moveToEndOfLine() { + navigationService.resetPreviousLineMovementOperation() + move(toBoundary: .line, inDirection: .forward) + } + + func moveToBeginningOfParagraph() { + navigationService.resetPreviousLineMovementOperation() + move(toBoundary: .paragraph, inDirection: .backward) + } + + func moveToEndOfParagraph() { + navigationService.resetPreviousLineMovementOperation() + move(toBoundary: .paragraph, inDirection: .forward) + } + + func moveToBeginningOfDocument() { + navigationService.resetPreviousLineMovementOperation() + move(toBoundary: .document, inDirection: .backward) + } + + func moveToEndOfDocument() { + navigationService.resetPreviousLineMovementOperation() + move(toBoundary: .document, inDirection: .forward) + } } private extension TextViewController { @@ -34,4 +68,12 @@ private extension TextViewController { let destinationLocation = navigationService.location(movingFrom: sourceLocation, by: granularity, offset: offset) selectedRange = NSRange(location: destinationLocation, length: 0) } + + private func move(toBoundary boundary: NavigationService.Boundary, inDirection direction: NavigationService.Direction) { + guard let sourceLocation = selectedRange?.location else { + return + } + let destinationLocation = navigationService.location(movingFrom: sourceLocation, toBoundary: boundary, inDirection: direction) + selectedRange = NSRange(location: destinationLocation, length: 0) + } } From dc48d41c9a62b4c81d5ad1ef5441efd27abbf2fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 21:01:42 +0100 Subject: [PATCH 062/232] Supports clicking to jump --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 9 +++++++++ .../TextViewController+Navigation.swift | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index ed5d1bb6b..4c8f27229 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -356,6 +356,10 @@ public final class TextView: NSView { scrollContentView: scrollContentView ) + public override var isFlipped: Bool { + true + } + private let scrollView = NSScrollView() private let scrollContentView = FlippedView() private let caretView = CaretView() @@ -529,6 +533,11 @@ public extension TextView { override func moveToEndOfDocument(_ sender: Any?) { textViewController.moveToEndOfDocument() } + + override func mouseDown(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + textViewController.moveToLocation(closestTo: point) + } } // MARK: - Window diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift index 39ae3f603..b544a80ca 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift @@ -58,6 +58,13 @@ extension TextViewController { navigationService.resetPreviousLineMovementOperation() move(toBoundary: .document, inDirection: .forward) } + + func moveToLocation(closestTo point: CGPoint) { + if let location = layoutManager.closestIndex(to: point) { + navigationService.resetPreviousLineMovementOperation() + selectedRange = NSRange(location: location, length: 0) + } + } } private extension TextViewController { From 8ff56e23f7b57560b0aef4f24b0506802bf07407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 21:30:50 +0100 Subject: [PATCH 063/232] Bridges themes to AppKit --- .../RunestoneOneDarkTheme/OneDarkTheme.swift | 67 ++++++++++++------- .../PlainTextTheme.swift | 37 +++++----- .../RunestoneThemeCommon/EditorTheme.swift | 9 ++- .../TomorrowNightTheme.swift | 63 ++++++++++------- .../TomorrowTheme.swift | 63 ++++++++++------- 5 files changed, 151 insertions(+), 88 deletions(-) diff --git a/Example/Themes/Sources/RunestoneOneDarkTheme/OneDarkTheme.swift b/Example/Themes/Sources/RunestoneOneDarkTheme/OneDarkTheme.swift index 4d721626d..3d9cd6e2e 100644 --- a/Example/Themes/Sources/RunestoneOneDarkTheme/OneDarkTheme.swift +++ b/Example/Themes/Sources/RunestoneOneDarkTheme/OneDarkTheme.swift @@ -1,55 +1,66 @@ +#if os(macOS) +import AppKit +#endif import Runestone import RunestoneThemeCommon +#if os(iOS) import UIKit +#endif public final class OneDarkTheme: EditorTheme { - public let backgroundColor = UIColor(namedInModule: "OneDarkBackground") + public let backgroundColor = MultiPlatformColor(namedInModule: "OneDarkBackground") + #if os(iOS) public let userInterfaceStyle: UIUserInterfaceStyle = .dark + #endif - public let font: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular) - public let textColor = UIColor(namedInModule: "OneDarkForeground") + public let font: MultiPlatformFont = .monospacedSystemFont(ofSize: 14, weight: .regular) + public let textColor = MultiPlatformColor(namedInModule: "OneDarkForeground") - public let gutterBackgroundColor = UIColor(namedInModule: "OneDarkCurrentLine") - public let gutterHairlineColor: UIColor = .opaqueSeparator + public let gutterBackgroundColor = MultiPlatformColor(namedInModule: "OneDarkCurrentLine") + #if os(iOS) + public let gutterHairlineColor: MultiPlatformColor = .opaqueSeparator + #else + public let gutterHairlineColor: MultiPlatformColor = .separatorColor + #endif - public let lineNumberColor = UIColor(namedInModule: "OneDarkForeground").withAlphaComponent(0.5) - public let lineNumberFont: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular) + public let lineNumberColor = MultiPlatformColor(namedInModule: "OneDarkForeground").withAlphaComponent(0.5) + public let lineNumberFont: MultiPlatformFont = .monospacedSystemFont(ofSize: 14, weight: .regular) - public let selectedLineBackgroundColor = UIColor(namedInModule: "OneDarkCurrentLine") - public let selectedLinesLineNumberColor = UIColor(namedInModule: "OneDarkForeground") - public let selectedLinesGutterBackgroundColor: UIColor = .clear + public let selectedLineBackgroundColor = MultiPlatformColor(namedInModule: "OneDarkCurrentLine") + public let selectedLinesLineNumberColor = MultiPlatformColor(namedInModule: "OneDarkForeground") + public let selectedLinesGutterBackgroundColor: MultiPlatformColor = .clear - public let invisibleCharactersColor = UIColor(namedInModule: "OneDarkForeground").withAlphaComponent(0.7) + public let invisibleCharactersColor = MultiPlatformColor(namedInModule: "OneDarkForeground").withAlphaComponent(0.7) - public let pageGuideHairlineColor = UIColor(namedInModule: "OneDarkForeground") - public let pageGuideBackgroundColor = UIColor(namedInModule: "OneDarkCurrentLine") + public let pageGuideHairlineColor = MultiPlatformColor(namedInModule: "OneDarkForeground") + public let pageGuideBackgroundColor = MultiPlatformColor(namedInModule: "OneDarkCurrentLine") - public let markedTextBackgroundColor = UIColor(namedInModule: "OneDarkForeground").withAlphaComponent(0.1) + public let markedTextBackgroundColor = MultiPlatformColor(namedInModule: "OneDarkForeground").withAlphaComponent(0.1) public let markedTextBackgroundCornerRadius: CGFloat = 4 public init() {} - public func textColor(for rawHighlightName: String) -> UIColor? { + public func textColor(for rawHighlightName: String) -> MultiPlatformColor? { guard let highlightName = HighlightName(rawHighlightName) else { return nil } switch highlightName { case .comment: - return UIColor(namedInModule: "OneDarkComment") + return MultiPlatformColor(namedInModule: "OneDarkComment") case .operator, .punctuation: - return UIColor(namedInModule: "OneDarkForeground").withAlphaComponent(0.75) + return MultiPlatformColor(namedInModule: "OneDarkForeground").withAlphaComponent(0.75) case .property: - return UIColor(namedInModule: "OneDarkAqua") + return MultiPlatformColor(namedInModule: "OneDarkAqua") case .function: - return UIColor(namedInModule: "OneDarkBlue") + return MultiPlatformColor(namedInModule: "OneDarkBlue") case .string: - return UIColor(namedInModule: "OneDarkGreen") + return MultiPlatformColor(namedInModule: "OneDarkGreen") case .number: - return UIColor(namedInModule: "OneDarkYellow") + return MultiPlatformColor(namedInModule: "OneDarkYellow") case .keyword: - return UIColor(namedInModule: "OneDarkPurple") + return MultiPlatformColor(namedInModule: "OneDarkPurple") case .variableBuiltin, .constantBuiltin: - return UIColor(namedInModule: "OneDarkRed") + return MultiPlatformColor(namedInModule: "OneDarkRed") } } @@ -62,8 +73,16 @@ public final class OneDarkTheme: EditorTheme { } } -private extension UIColor { +#if os(iOS) +public extension UIColor { convenience init(namedInModule name: String) { self.init(named: name, in: .module, compatibleWith: nil)! } } +#else +public extension NSColor { + convenience init(namedInModule name: String) { + self.init(named: name, bundle: .module)! + } +} +#endif diff --git a/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift b/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift index 214af4002..3d70b03f6 100644 --- a/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift +++ b/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift @@ -1,35 +1,42 @@ +#if os(macOS) +import AppKit +#endif import Runestone import RunestoneThemeCommon +#if os(iOS) import UIKit +#endif public final class PlainTextTheme: EditorTheme { - public let backgroundColor: UIColor = .white + public let backgroundColor: MultiPlatformColor = .white + #if os(iOS) public let userInterfaceStyle: UIUserInterfaceStyle = .light + #endif - public let font: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular) - public let textColor: UIColor = .black + public let font: MultiPlatformFont = .monospacedSystemFont(ofSize: 14, weight: .regular) + public let textColor: MultiPlatformColor = .black - public let gutterBackgroundColor: UIColor = .white - public let gutterHairlineColor: UIColor = .white + public let gutterBackgroundColor: MultiPlatformColor = .white + public let gutterHairlineColor: MultiPlatformColor = .white - public let lineNumberColor: UIColor = .black.withAlphaComponent(0.5) - public let lineNumberFont: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular) + public let lineNumberColor: MultiPlatformColor = .black.withAlphaComponent(0.5) + public let lineNumberFont: MultiPlatformFont = .monospacedSystemFont(ofSize: 14, weight: .regular) - public let selectedLineBackgroundColor: UIColor = .black.withAlphaComponent(0.07) - public let selectedLinesLineNumberColor: UIColor = .black - public let selectedLinesGutterBackgroundColor: UIColor = .black.withAlphaComponent(0.07) + public let selectedLineBackgroundColor: MultiPlatformColor = .black.withAlphaComponent(0.07) + public let selectedLinesLineNumberColor: MultiPlatformColor = .black + public let selectedLinesGutterBackgroundColor: MultiPlatformColor = .black.withAlphaComponent(0.07) - public let invisibleCharactersColor: UIColor = .black.withAlphaComponent(0.5) + public let invisibleCharactersColor: MultiPlatformColor = .black.withAlphaComponent(0.5) - public let pageGuideHairlineColor: UIColor = .black.withAlphaComponent(0.1) - public let pageGuideBackgroundColor: UIColor = .black.withAlphaComponent(0.06) + public let pageGuideHairlineColor: MultiPlatformColor = .black.withAlphaComponent(0.1) + public let pageGuideBackgroundColor: MultiPlatformColor = .black.withAlphaComponent(0.06) - public let markedTextBackgroundColor: UIColor = .black.withAlphaComponent(0.1) + public let markedTextBackgroundColor: MultiPlatformColor = .black.withAlphaComponent(0.1) public let markedTextBackgroundCornerRadius: CGFloat = 4 public init() {} - public func textColor(for rawHighlightName: String) -> UIColor? { + public func textColor(for rawHighlightName: String) -> MultiPlatformColor? { return nil } diff --git a/Example/Themes/Sources/RunestoneThemeCommon/EditorTheme.swift b/Example/Themes/Sources/RunestoneThemeCommon/EditorTheme.swift index c9d0bfffb..f06f2e025 100644 --- a/Example/Themes/Sources/RunestoneThemeCommon/EditorTheme.swift +++ b/Example/Themes/Sources/RunestoneThemeCommon/EditorTheme.swift @@ -1,7 +1,14 @@ +#if os(macOS) +import AppKit +#endif import Runestone +#if os(iOS) import UIKit +#endif public protocol EditorTheme: Runestone.Theme { - var backgroundColor: UIColor { get } + var backgroundColor: MultiPlatformColor { get } + #if os(iOS) var userInterfaceStyle: UIUserInterfaceStyle { get } + #endif } diff --git a/Example/Themes/Sources/RunestoneTomorrowNightTheme/TomorrowNightTheme.swift b/Example/Themes/Sources/RunestoneTomorrowNightTheme/TomorrowNightTheme.swift index b1fd605f2..cab774016 100644 --- a/Example/Themes/Sources/RunestoneTomorrowNightTheme/TomorrowNightTheme.swift +++ b/Example/Themes/Sources/RunestoneTomorrowNightTheme/TomorrowNightTheme.swift @@ -1,55 +1,62 @@ +#if os(macOS) +import AppKit +#endif import Runestone import RunestoneThemeCommon +#if os(iOS) import UIKit +#endif public final class TomorrowNightTheme: EditorTheme { - public let backgroundColor = UIColor(namedInModule: "TomorrowNightBackground") + public let backgroundColor = MultiPlatformColor(namedInModule: "TomorrowNightBackground") + #if os(iOS) public let userInterfaceStyle: UIUserInterfaceStyle = .dark + #endif - public let font: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular) - public let textColor = UIColor(namedInModule: "TomorrowNightForeground") + public let font: MultiPlatformFont = .monospacedSystemFont(ofSize: 14, weight: .regular) + public let textColor = MultiPlatformColor(namedInModule: "TomorrowNightForeground") - public let gutterBackgroundColor = UIColor(namedInModule: "TomorrowNightCurrentLine") - public let gutterHairlineColor = UIColor(namedInModule: "TomorrowNightComment") + public let gutterBackgroundColor = MultiPlatformColor(namedInModule: "TomorrowNightCurrentLine") + public let gutterHairlineColor = MultiPlatformColor(namedInModule: "TomorrowNightComment") - public let lineNumberColor = UIColor(namedInModule: "TomorrowNightForeground").withAlphaComponent(0.5) - public let lineNumberFont: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular) + public let lineNumberColor = MultiPlatformColor(namedInModule: "TomorrowNightForeground").withAlphaComponent(0.5) + public let lineNumberFont: MultiPlatformFont = .monospacedSystemFont(ofSize: 14, weight: .regular) - public let selectedLineBackgroundColor = UIColor(namedInModule: "TomorrowNightCurrentLine") - public let selectedLinesLineNumberColor = UIColor(namedInModule: "TomorrowNightForeground") - public let selectedLinesGutterBackgroundColor: UIColor = .clear + public let selectedLineBackgroundColor = MultiPlatformColor(namedInModule: "TomorrowNightCurrentLine") + public let selectedLinesLineNumberColor = MultiPlatformColor(namedInModule: "TomorrowNightForeground") + public let selectedLinesGutterBackgroundColor: MultiPlatformColor = .clear - public let invisibleCharactersColor = UIColor(namedInModule: "TomorrowNightForeground").withAlphaComponent(0.7) + public let invisibleCharactersColor = MultiPlatformColor(namedInModule: "TomorrowNightForeground").withAlphaComponent(0.7) - public let pageGuideHairlineColor = UIColor(namedInModule: "TomorrowNightForeground") - public let pageGuideBackgroundColor = UIColor(namedInModule: "TomorrowNightCurrentLine") + public let pageGuideHairlineColor = MultiPlatformColor(namedInModule: "TomorrowNightForeground") + public let pageGuideBackgroundColor = MultiPlatformColor(namedInModule: "TomorrowNightCurrentLine") - public let markedTextBackgroundColor = UIColor(namedInModule: "TomorrowNightForeground").withAlphaComponent(0.1) + public let markedTextBackgroundColor = MultiPlatformColor(namedInModule: "TomorrowNightForeground").withAlphaComponent(0.1) public let markedTextBackgroundCornerRadius: CGFloat = 4 public init() {} - public func textColor(for rawHighlightName: String) -> UIColor? { + public func textColor(for rawHighlightName: String) -> MultiPlatformColor? { guard let highlightName = HighlightName(rawHighlightName) else { return nil } switch highlightName { case .comment: - return UIColor(namedInModule: "TomorrowNightComment") + return MultiPlatformColor(namedInModule: "TomorrowNightComment") case .operator, .punctuation: - return UIColor(namedInModule: "TomorrowNightForeground").withAlphaComponent(0.75) + return MultiPlatformColor(namedInModule: "TomorrowNightForeground").withAlphaComponent(0.75) case .property: - return UIColor(namedInModule: "TomorrowNightAqua") + return MultiPlatformColor(namedInModule: "TomorrowNightAqua") case .function: - return UIColor(namedInModule: "TomorrowNightBlue") + return MultiPlatformColor(namedInModule: "TomorrowNightBlue") case .string: - return UIColor(namedInModule: "TomorrowNightGreen") + return MultiPlatformColor(namedInModule: "TomorrowNightGreen") case .number: - return UIColor(namedInModule: "TomorrowNightOrange") + return MultiPlatformColor(namedInModule: "TomorrowNightOrange") case .keyword: - return UIColor(namedInModule: "TomorrowNightPurple") + return MultiPlatformColor(namedInModule: "TomorrowNightPurple") case .variableBuiltin, .constantBuiltin: - return UIColor(namedInModule: "TomorrowNightRed") + return MultiPlatformColor(namedInModule: "TomorrowNightRed") } } @@ -62,8 +69,16 @@ public final class TomorrowNightTheme: EditorTheme { } } -private extension UIColor { +#if os(iOS) +public extension UIColor { convenience init(namedInModule name: String) { self.init(named: name, in: .module, compatibleWith: nil)! } } +#else +public extension NSColor { + convenience init(namedInModule name: String) { + self.init(named: name, bundle: .module)! + } +} +#endif diff --git a/Example/Themes/Sources/RunestoneTomorrowTheme/TomorrowTheme.swift b/Example/Themes/Sources/RunestoneTomorrowTheme/TomorrowTheme.swift index ab3878f9f..d98c86638 100644 --- a/Example/Themes/Sources/RunestoneTomorrowTheme/TomorrowTheme.swift +++ b/Example/Themes/Sources/RunestoneTomorrowTheme/TomorrowTheme.swift @@ -1,55 +1,62 @@ +#if os(macOS) +import AppKit +#endif import Runestone import RunestoneThemeCommon +#if os(iOS) import UIKit +#endif public final class TomorrowTheme: EditorTheme { - public let backgroundColor = UIColor(namedInModule: "TomorrowBackground") + public let backgroundColor = MultiPlatformColor(namedInModule: "TomorrowBackground") + #if os(iOS) public let userInterfaceStyle: UIUserInterfaceStyle = .light + #endif - public let font: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular) - public let textColor = UIColor(namedInModule: "TomorrowForeground") + public let font: MultiPlatformFont = .monospacedSystemFont(ofSize: 14, weight: .regular) + public let textColor = MultiPlatformColor(namedInModule: "TomorrowForeground") - public let gutterBackgroundColor = UIColor(namedInModule: "TomorrowCurrentLine") - public let gutterHairlineColor = UIColor(namedInModule: "TomorrowComment") + public let gutterBackgroundColor = MultiPlatformColor(namedInModule: "TomorrowCurrentLine") + public let gutterHairlineColor = MultiPlatformColor(namedInModule: "TomorrowComment") - public let lineNumberColor = UIColor(namedInModule: "TomorrowForeground").withAlphaComponent(0.5) - public let lineNumberFont: UIFont = .monospacedSystemFont(ofSize: 14, weight: .regular) + public let lineNumberColor = MultiPlatformColor(namedInModule: "TomorrowForeground").withAlphaComponent(0.5) + public let lineNumberFont: MultiPlatformFont = .monospacedSystemFont(ofSize: 14, weight: .regular) - public let selectedLineBackgroundColor = UIColor(namedInModule: "TomorrowCurrentLine") - public let selectedLinesLineNumberColor = UIColor(namedInModule: "TomorrowForeground") - public let selectedLinesGutterBackgroundColor: UIColor = .clear + public let selectedLineBackgroundColor = MultiPlatformColor(namedInModule: "TomorrowCurrentLine") + public let selectedLinesLineNumberColor = MultiPlatformColor(namedInModule: "TomorrowForeground") + public let selectedLinesGutterBackgroundColor: MultiPlatformColor = .clear - public let invisibleCharactersColor = UIColor(namedInModule: "TomorrowForeground").withAlphaComponent(0.7) + public let invisibleCharactersColor = MultiPlatformColor(namedInModule: "TomorrowForeground").withAlphaComponent(0.7) - public let pageGuideHairlineColor = UIColor(namedInModule: "TomorrowForeground") - public let pageGuideBackgroundColor = UIColor(namedInModule: "TomorrowCurrentLine") + public let pageGuideHairlineColor = MultiPlatformColor(namedInModule: "TomorrowForeground") + public let pageGuideBackgroundColor = MultiPlatformColor(namedInModule: "TomorrowCurrentLine") - public let markedTextBackgroundColor = UIColor(namedInModule: "TomorrowForeground").withAlphaComponent(0.1) + public let markedTextBackgroundColor = MultiPlatformColor(namedInModule: "TomorrowForeground").withAlphaComponent(0.1) public let markedTextBackgroundCornerRadius: CGFloat = 4 public init() {} - public func textColor(for rawHighlightName: String) -> UIColor? { + public func textColor(for rawHighlightName: String) -> MultiPlatformColor? { guard let highlightName = HighlightName(rawHighlightName) else { return nil } switch highlightName { case .comment: - return UIColor(namedInModule: "TomorrowComment") + return MultiPlatformColor(namedInModule: "TomorrowComment") case .operator, .punctuation: - return UIColor(namedInModule: "TomorrowForeground").withAlphaComponent(0.75) + return MultiPlatformColor(namedInModule: "TomorrowForeground").withAlphaComponent(0.75) case .property: - return UIColor(namedInModule: "TomorrowAqua") + return MultiPlatformColor(namedInModule: "TomorrowAqua") case .function: - return UIColor(namedInModule: "TomorrowBlue") + return MultiPlatformColor(namedInModule: "TomorrowBlue") case .string: - return UIColor(namedInModule: "TomorrowGreen") + return MultiPlatformColor(namedInModule: "TomorrowGreen") case .number: - return UIColor(namedInModule: "TomorrowOrange") + return MultiPlatformColor(namedInModule: "TomorrowOrange") case .keyword: - return UIColor(namedInModule: "TomorrowPurple") + return MultiPlatformColor(namedInModule: "TomorrowPurple") case .variableBuiltin, .constantBuiltin: - return UIColor(namedInModule: "TomorrowRed") + return MultiPlatformColor(namedInModule: "TomorrowRed") } } @@ -62,8 +69,16 @@ public final class TomorrowTheme: EditorTheme { } } -private extension UIColor { +#if os(iOS) +public extension UIColor { convenience init(namedInModule name: String) { self.init(named: name, in: .module, compatibleWith: nil)! } } +#else +public extension NSColor { + convenience init(namedInModule name: String) { + self.init(named: name, bundle: .module)! + } +} +#endif From 31f8d0ed9a053f294acf453855b588254f879c76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 21:30:56 +0100 Subject: [PATCH 064/232] Adds theme property --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 4c8f27229..14e5409c3 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -20,6 +20,15 @@ public final class TextView: NSView { scrollView.contentSize = textViewController.contentSize } } + /// Colors and fonts to be used by the editor. + public var theme: Theme { + get { + return textViewController.theme + } + set { + textViewController.theme = newValue + } + } /// Character pairs are used by the editor to automatically insert a trailing character when the user types the leading character. /// /// Common usages of this includes the \" character to surround strings and { } to surround a scope. From 4449fb9aa0d3950dbc4f49be6a2aa809c28f5eeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 21:31:01 +0100 Subject: [PATCH 065/232] Disables drawing of background --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 14e5409c3..58fcae2aa 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -392,6 +392,7 @@ public final class TextView: NSView { textViewController.delegate = self textViewController.selectedRange = NSRange(location: 0, length: 0) scrollView.borderType = .noBorder + scrollView.drawsBackground = false scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = true scrollView.documentView = scrollContentView From 4bd8db5cee76d5ed5b744d2733c26fe3a9be8853 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 21:31:06 +0100 Subject: [PATCH 066/232] Supports inserting tab --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 58fcae2aa..4b069016e 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -488,6 +488,10 @@ public extension TextView { textViewController.indentController.insertLineBreak(in: textViewController.rangeForInsertingText, using: lineEndings) } + override func insertTab(_ sender: Any?) { + textViewController.replaceText(in: textViewController.rangeForInsertingText, with: "\t") + } + override func moveLeft(_ sender: Any?) { textViewController.moveLeft() } From 82cc1f20806af81e7b63afb82385989def679aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 21:31:24 +0100 Subject: [PATCH 067/232] Applies theme --- Example/MacExample/MainViewController.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index 7e24e0a97..141638eea 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -1,5 +1,9 @@ import Cocoa import Runestone +import RunestoneOneDarkTheme +import RunestoneThemeCommon +import RunestoneTomorrowNightTheme +import RunestoneTomorrowTheme final class MainViewController: NSViewController { private let textView: TextView = { @@ -16,6 +20,7 @@ final class MainViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() setupTextView() + applyTheme(OneDarkTheme()) } } @@ -29,4 +34,10 @@ private extension MainViewController { textView.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) } + + private func applyTheme(_ theme: EditorTheme) { + textView.theme = theme + textView.wantsLayer = true + textView.layer?.backgroundColor = theme.backgroundColor.cgColor + } } From 427136321a068a0c9dcad2c6c2a391a9e8475237 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 21:33:08 +0100 Subject: [PATCH 068/232] Shows invisible characters --- Example/MacExample/MainViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index 141638eea..ec5c23283 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -10,6 +10,10 @@ final class MainViewController: NSViewController { let this = TextView() this.translatesAutoresizingMaskIntoConstraints = false this.textContainerInset = NSEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) + this.showTabs = true + this.showSpaces = true + this.showLineBreaks = true + this.showSoftLineBreaks = true return this }() From a764301b3e9d02cfec90c29cd0a7a7f8582b6fa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Tue, 31 Jan 2023 21:33:16 +0100 Subject: [PATCH 069/232] Sets lineHeightMultiplier and kern --- Example/MacExample/MainViewController.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index ec5c23283..853a5c8f0 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -14,6 +14,8 @@ final class MainViewController: NSViewController { this.showSpaces = true this.showLineBreaks = true this.showSoftLineBreaks = true + this.lineHeightMultiplier = 1.2 + this.kern = 0.3 return this }() From cec198b2288648210afbefcba37878a6d351c416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:49:06 +0100 Subject: [PATCH 070/232] Fixes conversion of clicks --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 4b069016e..3a838d09c 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -549,7 +549,7 @@ public extension TextView { } override func mouseDown(with event: NSEvent) { - let point = convert(event.locationInWindow, from: nil) + let point = scrollContentView.convert(event.locationInWindow, from: nil) textViewController.moveToLocation(closestTo: point) } } From 2656524ae52e88b05eb744302f7bc7a87ddd6f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:49:11 +0100 Subject: [PATCH 071/232] Removes debug log --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 3a838d09c..4d4f892e1 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -590,7 +590,6 @@ private extension TextView { @objc private func scrollViewBoundsDidChange() { textViewController.viewport = CGRect(origin: scrollView.contentOffset, size: frame.size) textViewController.layoutIfNeeded() - print(textViewController.viewport) } } From 871c2dd43958aa87cf9ed50bd9c9e4025a023db2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:50:11 +0100 Subject: [PATCH 072/232] Properly handles becomeFirstResponder and resignFirstResponder --- .../TextView/Core/Mac/TextView_Mac.swift | 45 +++++++++++++++---- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 4d4f892e1..0eb538e57 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -386,6 +386,23 @@ public final class TextView: NSView { } } } + private var shouldBeginEditing: Bool { + guard isEditable else { + return false + } + if let editorDelegate = editorDelegate { + return editorDelegate.textViewShouldBeginEditing(self) + } else { + return true + } + } + private var shouldEndEditing: Bool { + if let editorDelegate = editorDelegate { + return editorDelegate.textViewShouldEndEditing(self) + } else { + return true + } + } public init() { super.init(frame: .zero) @@ -413,21 +430,33 @@ public final class TextView: NSView { } @discardableResult - public override func becomeFirstResponder() -> Bool { - let result = super.becomeFirstResponder() - if result { + override open func becomeFirstResponder() -> Bool { + guard !isEditing && shouldBeginEditing else { + return false + } + let didBecomeFirstResponder = super.becomeFirstResponder() + if didBecomeFirstResponder { isFirstResponder = true + textViewController.isEditing = true + editorDelegate?.textViewDidBeginEditing(self) + } else { + textViewController.isEditing = false } - return result + return didBecomeFirstResponder } @discardableResult - public override func resignFirstResponder() -> Bool { - let result = super.resignFirstResponder() - if result { + override open func resignFirstResponder() -> Bool { + guard isEditing && shouldEndEditing else { + return false + } + let didResignFirstResponder = super.resignFirstResponder() + if didResignFirstResponder { isFirstResponder = false + textViewController.isEditing = false + editorDelegate?.textViewDidEndEditing(self) } - return result + return didResignFirstResponder } public override func resizeSubviews(withOldSize oldSize: NSSize) { From 02ce86e5422e7e66f0bb30388375f58aaa4cb965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:50:22 +0100 Subject: [PATCH 073/232] Ensures gutter hairline has background --- Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift b/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift index ba9fc9308..c3a183527 100644 --- a/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift +++ b/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift @@ -21,6 +21,9 @@ final class GutterBackgroundView: MultiPlatformView { override init(frame: CGRect = .zero) { super.init(frame: .zero) + #if os(macOS) + hairlineView.wantsLayer = true + #endif addSubview(hairlineView) } From 262df57be9f855ca4020069a1bf38db8de0b7689 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:50:33 +0100 Subject: [PATCH 074/232] Links themes --- Example/Example.xcodeproj/project.pbxproj | 35 +++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 7a4fccbff..6f6f9cbf1 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -7,6 +7,11 @@ objects = { /* Begin PBXBuildFile section */ + 721103592989ACDD00DDFE48 /* RunestoneOneDarkTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 721103582989ACDD00DDFE48 /* RunestoneOneDarkTheme */; }; + 7211035B2989ACDD00DDFE48 /* RunestonePlainTextTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 7211035A2989ACDD00DDFE48 /* RunestonePlainTextTheme */; }; + 7211035D2989ACDD00DDFE48 /* RunestoneThemeCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 7211035C2989ACDD00DDFE48 /* RunestoneThemeCommon */; }; + 7211035F2989ACDD00DDFE48 /* RunestoneTomorrowNightTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 7211035E2989ACDD00DDFE48 /* RunestoneTomorrowNightTheme */; }; + 721103612989ACDD00DDFE48 /* RunestoneTomorrowTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 721103602989ACDD00DDFE48 /* RunestoneTomorrowTheme */; }; 7216EAC82829A3C6001B6D39 /* RunestoneJavaScriptLanguage in Frameworks */ = {isa = PBXBuildFile; productRef = 7216EAC72829A3C6001B6D39 /* RunestoneJavaScriptLanguage */; }; 7216EACA2829A3C6001B6D39 /* RunestoneOneDarkTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 7216EAC92829A3C6001B6D39 /* RunestoneOneDarkTheme */; }; 7216EACC2829A3C6001B6D39 /* RunestonePlainTextTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 7216EACB2829A3C6001B6D39 /* RunestonePlainTextTheme */; }; @@ -71,7 +76,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 721103592989ACDD00DDFE48 /* RunestoneOneDarkTheme in Frameworks */, 729ECE5E2983F9B90049AFF5 /* Runestone in Frameworks */, + 7211035F2989ACDD00DDFE48 /* RunestoneTomorrowNightTheme in Frameworks */, + 721103612989ACDD00DDFE48 /* RunestoneTomorrowTheme in Frameworks */, + 7211035B2989ACDD00DDFE48 /* RunestonePlainTextTheme in Frameworks */, + 7211035D2989ACDD00DDFE48 /* RunestoneThemeCommon in Frameworks */, 729ECE5C2983F9B90049AFF5 /* RunestoneJavaScriptLanguage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -217,6 +227,11 @@ packageProductDependencies = ( 729ECE5B2983F9B90049AFF5 /* RunestoneJavaScriptLanguage */, 729ECE5D2983F9B90049AFF5 /* Runestone */, + 721103582989ACDD00DDFE48 /* RunestoneOneDarkTheme */, + 7211035A2989ACDD00DDFE48 /* RunestonePlainTextTheme */, + 7211035C2989ACDD00DDFE48 /* RunestoneThemeCommon */, + 7211035E2989ACDD00DDFE48 /* RunestoneTomorrowNightTheme */, + 721103602989ACDD00DDFE48 /* RunestoneTomorrowTheme */, ); productName = MacExample; productReference = 729ECE4C2983F5B60049AFF5 /* MacExample.app */; @@ -670,6 +685,26 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + 721103582989ACDD00DDFE48 /* RunestoneOneDarkTheme */ = { + isa = XCSwiftPackageProductDependency; + productName = RunestoneOneDarkTheme; + }; + 7211035A2989ACDD00DDFE48 /* RunestonePlainTextTheme */ = { + isa = XCSwiftPackageProductDependency; + productName = RunestonePlainTextTheme; + }; + 7211035C2989ACDD00DDFE48 /* RunestoneThemeCommon */ = { + isa = XCSwiftPackageProductDependency; + productName = RunestoneThemeCommon; + }; + 7211035E2989ACDD00DDFE48 /* RunestoneTomorrowNightTheme */ = { + isa = XCSwiftPackageProductDependency; + productName = RunestoneTomorrowNightTheme; + }; + 721103602989ACDD00DDFE48 /* RunestoneTomorrowTheme */ = { + isa = XCSwiftPackageProductDependency; + productName = RunestoneTomorrowTheme; + }; 7216EAC72829A3C6001B6D39 /* RunestoneJavaScriptLanguage */ = { isa = XCSwiftPackageProductDependency; productName = RunestoneJavaScriptLanguage; From 6151dae7906ecf5032d7cc19ae76752b222c39e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:51:13 +0100 Subject: [PATCH 075/232] Makes TextView open --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 0eb538e57..28645bcc5 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -1,7 +1,7 @@ #if os(macOS) import AppKit -public final class TextView: NSView { +open class TextView: NSView { public weak var editorDelegate: TextViewDelegate? public override var acceptsFirstResponder: Bool { true @@ -421,7 +421,7 @@ public final class TextView: NSView { setupScrollViewBoundsDidChangeObserver() } - required init?(coder: NSCoder) { + required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } From 5c83251f2ac1dc008d06f4a0c4996025d3dd21a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:51:36 +0100 Subject: [PATCH 076/232] Fixes compile issues --- .../TextView/Core/iOS/TextInputStringTokenizer.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift index 6ea6ab9c2..f34504c7e 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift @@ -39,11 +39,11 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { guard let indexedPosition = position as? IndexedPosition else { return false } - guard let granularity = StringTokenizer.Granularity(granularity) else { + guard let boundary = StringTokenizer.Boundary(granularity) else { return super.isPosition(position, atBoundary: granularity, inDirection: direction) } let direction = StringTokenizer.Direction(direction) - return stringTokenizer.isLocation(indexedPosition.index, atBoundary: granularity, inDirection: direction) + return stringTokenizer.isLocation(indexedPosition.index, atBoundary: boundary, inDirection: direction) } override func isPosition( @@ -62,11 +62,11 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { guard let indexedPosition = position as? IndexedPosition else { return nil } - guard let granularity = StringTokenizer.Granularity(granularity) else { + guard let boundary = StringTokenizer.Boundary(granularity) else { return super.position(from: position, toBoundary: granularity, inDirection: direction) } let direction = StringTokenizer.Direction(direction) - guard let location = stringTokenizer.location(from: indexedPosition.index, toBoundary: granularity, inDirection: direction) else { + guard let location = stringTokenizer.location(from: indexedPosition.index, toBoundary: boundary, inDirection: direction) else { return nil } return IndexedPosition(index: location) @@ -81,7 +81,7 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { } } -private extension StringTokenizer.Granularity { +private extension StringTokenizer.Boundary { init?(_ granularity: UITextGranularity) { switch granularity { case .word: From 32b532ddf2b38c9ca50933869d5006df41e64608 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:51:51 +0100 Subject: [PATCH 077/232] Renames delegateAllowsEditingToBegin to shouldBeginEditing --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 0f33b17c4..a441a44fc 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -542,7 +542,7 @@ open class TextView: UIScrollView { private var textRangeAdjustmentGestureRecognizers: Set = [] private var previousSelectedRangeDuringGestureHandling: NSRange? private var isPerformingNonEditableTextInteraction = false - private var delegateAllowsEditingToBegin: Bool { + private var shouldBeginEditing: Bool { guard isEditable else { return false } @@ -625,7 +625,7 @@ open class TextView: UIScrollView { /// Asks UIKit to make this object the first responder in its window. @discardableResult override open func becomeFirstResponder() -> Bool { - guard !isEditing && delegateAllowsEditingToBegin else { + guard !isEditing && shouldBeginEditing else { return false } if canBecomeFirstResponder { @@ -1203,7 +1203,7 @@ extension TextView: SearchControllerDelegate { extension TextView: UIGestureRecognizerDelegate { override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer === tapGestureRecognizer { - return !isEditing && !isDragging && !isDecelerating && delegateAllowsEditingToBegin + return !isEditing && !isDragging && !isDecelerating && shouldBeginEditing } else { return super.gestureRecognizerShouldBegin(gestureRecognizer) } From a4c631b0e1cd9072750970afb829f48e5af4b4a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:52:37 +0100 Subject: [PATCH 078/232] Adds gutter to Mac app --- Example/MacExample/MainViewController.swift | 7 +- .../TextView/Core/LayoutManager.swift | 76 ++++++++++--------- .../TextView/Core/Mac/TextView_Mac.swift | 40 +++++++--- .../TextViewController.swift | 15 +--- .../TextView/Core/iOS/TextView_iOS.swift | 10 +-- 5 files changed, 88 insertions(+), 60 deletions(-) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index 853a5c8f0..1b5bf8612 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -9,13 +9,18 @@ final class MainViewController: NSViewController { private let textView: TextView = { let this = TextView() this.translatesAutoresizingMaskIntoConstraints = false - this.textContainerInset = NSEdgeInsets(top: 5, left: 5, bottom: 5, right: 5) + this.textContainerInset = NSEdgeInsets(top: 0, left: 5, bottom: 0, right: 5) + this.showLineNumbers = true this.showTabs = true this.showSpaces = true this.showLineBreaks = true this.showSoftLineBreaks = true this.lineHeightMultiplier = 1.2 this.kern = 0.3 + this.lineSelectionDisplayType = .line + this.gutterLeadingPadding = 4 + this.gutterTrailingPadding = 4 + this.isLineWrappingEnabled = false return this }() diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index ae3f2824c..161ea87af 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -12,13 +12,6 @@ protocol LayoutManagerDelegate: AnyObject { final class LayoutManager { weak var delegate: LayoutManagerDelegate? - weak var containerView: MultiPlatformView? { - didSet { - if containerView != oldValue { - setupViewHierarchy() - } - } - } var lineManager: LineManager var stringView: StringView var scrollViewWidth: CGFloat = 0 @@ -92,7 +85,7 @@ final class LayoutManager { var lineHeightMultiplier: CGFloat = 1 var constrainingLineWidth: CGFloat { if isLineWrappingEnabled { - return scrollViewWidth - leadingLineSpacing - textContainerInset.right - safeAreaInsets.left - safeAreaInsets.right + return scrollViewWidth - textContainerInset.left - textContainerInset.right - safeAreaInsets.left - safeAreaInsets.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. @@ -108,24 +101,17 @@ final class LayoutManager { } // MARK: - Views - let gutterContainerView = MultiPlatformView() + let linesContainerView = FlippedView() + let lineSelectionBackgroundView = FlippedView() + let gutterContainerView = FlippedView() private var lineFragmentViewReuseQueue = ViewReuseQueue() private var lineNumberLabelReuseQueue = ViewReuseQueue() private var visibleLineIDs: Set = [] - private let linesContainerView = FlippedView() private let gutterBackgroundView = GutterBackgroundView() - private let lineNumbersContainerView = MultiPlatformView() - private let gutterSelectionBackgroundView = MultiPlatformView() - private let lineSelectionBackgroundView = MultiPlatformView() + private let gutterSelectionBackgroundView = FlippedView() + private let lineNumbersContainerView = FlippedView() // MARK: - Sizing - private var leadingLineSpacing: CGFloat { - if showLineNumbers { - return gutterWidthService.gutterWidth + textContainerInset.left - } else { - return textContainerInset.left - } - } private var insetViewport: CGRect { let x = viewport.minX - textContainerInset.left let y = viewport.minY - textContainerInset.top @@ -170,15 +156,19 @@ final class LayoutManager { #if os(iOS) self.linesContainerView.isUserInteractionEnabled = false self.lineNumbersContainerView.isUserInteractionEnabled = false - self.gutterContainerView.isUserInteractionEnabled = false self.gutterBackgroundView.isUserInteractionEnabled = false self.gutterSelectionBackgroundView.isUserInteractionEnabled = false self.lineSelectionBackgroundView.isUserInteractionEnabled = false + #else + self.gutterBackgroundView.wantsLayer = true + self.gutterSelectionBackgroundView.wantsLayer = true + self.lineSelectionBackgroundView.wantsLayer = true #endif self.updateShownViews() #if os(iOS) subscribeToMemoryWarningNotification() #endif + setupViewHierarchy() } func redisplayVisibleLines() { @@ -256,7 +246,7 @@ extension LayoutManager { } func closestIndex(to point: CGPoint) -> Int? { - let adjustedXPosition = point.x - leadingLineSpacing + let adjustedXPosition = point.x - textContainerInset.left let adjustedYPosition = point.y - textContainerInset.top let adjustedPoint = CGPoint(x: adjustedXPosition, y: adjustedYPosition) if let line = lineManager.line(containingYOffset: adjustedPoint.y), let lineController = lineControllerStorage[line.id] { @@ -319,20 +309,38 @@ extension LayoutManager { } } + #if os(iOS) + func bringGutterToFront() { + gutterContainerView.superview?.bringSubviewToFront(gutterContainerView) + } + #endif + private func layoutGutter() { let totalGutterWidth = safeAreaInsets.left + gutterWidthService.gutterWidth let contentSize = contentSizeService.contentSize - gutterContainerView.frame = CGRect(x: viewport.minX, y: 0, width: totalGutterWidth, height: contentSize.height) - gutterBackgroundView.frame = CGRect(x: 0, y: viewport.minY, width: totalGutterWidth, height: viewport.height) - lineNumbersContainerView.frame = CGRect(x: 0, y: 0, width: totalGutterWidth, height: contentSize.height) + gutterContainerView.frame = CGRect(x: 0, y: 0, width: totalGutterWidth, height: viewport.height) + #if os(iOS) + // Offset gutter background and line numbers on iOS as it is a child of the scroll view and we want it to appear static. + gutterBackgroundView.frame = CGRect(x: viewport.minX, y: viewport.minY, width: totalGutterWidth, height: viewport.height) + lineNumbersContainerView.frame = CGRect(x: viewport.minX, y: 0, width: totalGutterWidth, height: contentSize.height) + #else + gutterBackgroundView.frame = CGRect(x: 0, y: 0, width: totalGutterWidth, height: viewport.height) + // Manually offset line numbers on macOS as the container is not a child of the scroll view. + lineNumbersContainerView.frame = CGRect(x: 0, y: viewport.minY * -1, width: totalGutterWidth, height: contentSize.height) + #endif } private func layoutLineSelection() { if let rect = getLineSelectionRect() { let totalGutterWidth = safeAreaInsets.left + gutterWidthService.gutterWidth gutterSelectionBackgroundView.frame = CGRect(x: 0, y: rect.minY, width: totalGutterWidth, height: rect.height) - let lineSelectionBackgroundOrigin = CGPoint(x: viewport.minX + totalGutterWidth, y: rect.minY) - let lineSelectionBackgroundSize = CGSize(width: scrollViewWidth - gutterWidthService.gutterWidth, height: rect.height) + #if os(iOS) + let lineSelectionBackgroundOrigin = CGPoint(x: totalGutterWidth, y: rect.minY) + #else + // Manually offset selection background on macOS as it is not a child of the scroll view. + let lineSelectionBackgroundOrigin = CGPoint(x: totalGutterWidth, y: rect.minY + viewport.minY * -1) + #endif + let lineSelectionBackgroundSize = CGSize(width: scrollViewWidth - totalGutterWidth, height: rect.height) lineSelectionBackgroundView.frame = CGRect(origin: lineSelectionBackgroundOrigin, size: lineSelectionBackgroundSize) } } @@ -447,7 +455,8 @@ extension LayoutManager { } } let contentSize = contentSizeService.contentSize - linesContainerView.frame = CGRect(x: 0, y: 0, width: contentSize.width, height: contentSize.height) + let totalGutterWidth = safeAreaInsets.left + gutterWidthService.gutterWidth + linesContainerView.frame = CGRect(x: max(viewport.minX, 0) + totalGutterWidth, y: 0, width: contentSize.width, height: contentSize.height) // Update the visible lines and line fragments. Clean up everything that is not in the viewport anymore. visibleLineIDs = appearedLineIDs let disappearedLineIDs = oldVisibleLineIDs.subtracting(appearedLineIDs) @@ -498,8 +507,11 @@ extension LayoutManager { linesContainerView.addSubview(lineFragmentView) } lineFragmentController.lineFragmentView = lineFragmentView - let lineFragmentOrigin = CGPoint(x: leadingLineSpacing, y: textContainerInset.top + lineYPosition + lineFragment.yPosition) - let lineFragmentWidth = contentSizeService.contentWidth - leadingLineSpacing - textContainerInset.right + let lineFragmentOrigin = CGPoint( + x: max(viewport.minX, 0) * -1 + textContainerInset.left, + y: textContainerInset.top + lineYPosition + lineFragment.yPosition + ) + let lineFragmentWidth = contentSizeService.contentWidth - textContainerInset.left - textContainerInset.right let lineFragmentSize = CGSize(width: lineFragmentWidth, height: lineFragment.scaledSize.height) lineFragmentFrame = CGRect(origin: lineFragmentOrigin, size: lineFragmentSize) lineFragmentView.frame = lineFragmentFrame @@ -524,16 +536,12 @@ extension LayoutManager { // Remove views from view hierarchy lineSelectionBackgroundView.removeFromSuperview() linesContainerView.removeFromSuperview() - gutterContainerView.removeFromSuperview() gutterBackgroundView.removeFromSuperview() gutterSelectionBackgroundView.removeFromSuperview() lineNumbersContainerView.removeFromSuperview() let allLineNumberKeys = lineFragmentViewReuseQueue.visibleViews.keys lineFragmentViewReuseQueue.enqueueViews(withKeys: Set(allLineNumberKeys)) // Add views to view hierarchy - containerView?.addSubview(lineSelectionBackgroundView) - containerView?.addSubview(linesContainerView) - containerView?.addSubview(gutterContainerView) gutterContainerView.addSubview(gutterBackgroundView) gutterContainerView.addSubview(gutterSelectionBackgroundView) gutterContainerView.addSubview(lineNumbersContainerView) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 28645bcc5..fc8b20ecd 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -6,6 +6,20 @@ open class TextView: NSView { public override var acceptsFirstResponder: Bool { true } + public override var isFlipped: Bool { + true + } + /// A Boolean value that indicates whether the text view is editable. + public var isEditable: Bool { + get { + return textViewController.isEditable + } + set { + if newValue != isEditable { + textViewController.isEditable = newValue + } + } + } /// Whether the text view is in a state where the contents can be edited. public var isEditing: Bool { textViewController.isEditing @@ -358,17 +372,17 @@ open class TextView: NSView { textViewController.lineEndings = newValue } } - - private(set) lazy var textViewController = TextViewController( - textView: self, - scrollView: scrollView, - scrollContentView: scrollContentView - ) - - public override var isFlipped: Bool { - true + /// The color of the insertion point. This can be used to control the color of the caret. + public var insertionPointColor: NSColor = .label { + didSet { + if insertionPointColor != oldValue { + caretView.color = insertionPointColor + } + } } + private(set) lazy var textViewController = TextViewController(textView: self, scrollView: scrollView) + private let scrollView = NSScrollView() private let scrollContentView = FlippedView() private let caretView = CaretView() @@ -414,7 +428,10 @@ open class TextView: NSView { scrollView.hasHorizontalScroller = true scrollView.documentView = scrollContentView scrollView.contentView.postsBoundsChangedNotifications = true + scrollContentView.addSubview(textViewController.layoutManager.linesContainerView) scrollContentView.addSubview(caretView) + scrollView.addSubview(textViewController.layoutManager.gutterContainerView, positioned: .below, relativeTo: scrollView.horizontalScroller) + addSubview(textViewController.layoutManager.lineSelectionBackgroundView) addSubview(scrollView) setNeedsLayout() setupWindowObservers() @@ -469,6 +486,11 @@ open class TextView: NSView { updateCaretFrame() } + public override func layoutSubtreeIfNeeded() { + super.layoutSubtreeIfNeeded() + textViewController.layoutIfNeeded() + } + public override func viewDidMoveToWindow() { super.viewDidMoveToWindow() textViewController.performFullLayoutIfNeeded() diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index f4fc80d8d..5de034de1 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -26,22 +26,17 @@ final class TextViewController { fatalError("The scroll view has been deallocated or has not been assigned") } } - var scrollContentView: MultiPlatformView { - if let scrollContentView = _scrollContentView { - return scrollContentView - } else { - fatalError("The scroll content view has been deallocated or has not been assigned") - } - } private weak var _textView: TextView? private weak var _scrollView: MultiPlatformScrollView? - private weak var _scrollContentView: MultiPlatformView? var selectedRange: NSRange? { didSet { if selectedRange != oldValue { layoutManager.selectedRange = selectedRange layoutManager.setNeedsLayoutLineSelection() textView.setNeedsLayout() + #if os(macOS) + textView.layoutIfNeeded() + #endif delegate?.textViewController(self, didUpdateSelectedRange: selectedRange) } } @@ -530,10 +525,9 @@ final class TextViewController { } private var cancellables: Set = [] - init(textView: TextView, scrollView: MultiPlatformScrollView, scrollContentView: MultiPlatformView) { + init(textView: TextView, scrollView: MultiPlatformScrollView) { _textView = textView _scrollView = scrollView - _scrollContentView = scrollContentView lineManager = LineManager(stringView: stringView) highlightService = HighlightService(lineManager: lineManager) lineControllerFactory = LineControllerFactory( @@ -589,7 +583,6 @@ final class TextViewController { indentFont: theme.font ) layoutManager.delegate = self - layoutManager.containerView = scrollContentView applyThemeToChildren() indentController.delegate = self lineControllerStorage.delegate = self diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index a441a44fc..f5840c1b8 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -507,11 +507,7 @@ open class TextView: UIScrollView { } } - private(set) lazy var textViewController = TextViewController( - textView: self, - scrollView: self, - scrollContentView: self - ) + private(set) lazy var textViewController = TextViewController(textView: self, scrollView: self) private(set) lazy var customTokenizer = TextInputStringTokenizer( textInput: self, stringView: textViewController.stringView, @@ -579,6 +575,9 @@ open class TextView: UIScrollView { editMenuController.delegate = self editMenuController.setupEditMenu(in: self) textViewController.highlightNavigationController.delegate = self + addSubview(textViewController.layoutManager.lineSelectionBackgroundView) + addSubview(textViewController.layoutManager.linesContainerView) + addSubview(textViewController.layoutManager.gutterContainerView) } /// The initializer has not been implemented. @@ -612,6 +611,7 @@ open class TextView: UIScrollView { } textViewController.handleContentSizeUpdateIfNeeded() textViewController.viewport = CGRect(origin: contentOffset, size: frame.size) + textViewController.layoutManager.bringGutterToFront() } /// Called when the safe area of the view changes. From 31e05242afad5fce405be2977272e7866eea2866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:52:44 +0100 Subject: [PATCH 079/232] Sets insertion point color --- Example/MacExample/MainViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index 1b5bf8612..d71d7579c 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -50,5 +50,6 @@ private extension MainViewController { textView.theme = theme textView.wantsLayer = true textView.layer?.backgroundColor = theme.backgroundColor.cgColor + textView.insertionPointColor = theme.textColor } } From f5c65d2e9fd02c6b6318a4bc64a4021520c3196c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:55:46 +0100 Subject: [PATCH 080/232] Fixes selection placement on iOS --- .../TextView/Core/LayoutManager.swift | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index 161ea87af..008f7b247 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -331,18 +331,20 @@ extension LayoutManager { } private func layoutLineSelection() { - if let rect = getLineSelectionRect() { - let totalGutterWidth = safeAreaInsets.left + gutterWidthService.gutterWidth - gutterSelectionBackgroundView.frame = CGRect(x: 0, y: rect.minY, width: totalGutterWidth, height: rect.height) - #if os(iOS) - let lineSelectionBackgroundOrigin = CGPoint(x: totalGutterWidth, y: rect.minY) - #else - // Manually offset selection background on macOS as it is not a child of the scroll view. - let lineSelectionBackgroundOrigin = CGPoint(x: totalGutterWidth, y: rect.minY + viewport.minY * -1) - #endif - let lineSelectionBackgroundSize = CGSize(width: scrollViewWidth - totalGutterWidth, height: rect.height) - lineSelectionBackgroundView.frame = CGRect(origin: lineSelectionBackgroundOrigin, size: lineSelectionBackgroundSize) + guard let rect = getLineSelectionRect() else { + return } + let totalGutterWidth = safeAreaInsets.left + gutterWidthService.gutterWidth + gutterSelectionBackgroundView.frame = CGRect(x: 0, y: rect.minY, width: totalGutterWidth, height: rect.height) + #if os(iOS) + // Adjust x-offset to make it appear static as it is added to the scroll view. + let lineSelectionBackgroundOrigin = CGPoint(x: viewport.minX + totalGutterWidth, y: rect.minY) + #else + // Adjust y-offset on macOS to make it scroll as it is not a child of the scroll view. + let lineSelectionBackgroundOrigin = CGPoint(x: totalGutterWidth, y: rect.minY + viewport.minY * -1) + #endif + let lineSelectionBackgroundSize = CGSize(width: scrollViewWidth - totalGutterWidth, height: rect.height) + lineSelectionBackgroundView.frame = CGRect(origin: lineSelectionBackgroundOrigin, size: lineSelectionBackgroundSize) } private func getLineSelectionRect() -> CGRect? { From f2f4a84548f419f6ad4c0d58c3098888366462db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 20:59:56 +0100 Subject: [PATCH 081/232] Enables automatic scrolling by default --- .../TextView/Core/TextViewController/TextViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 5de034de1..7fd504b71 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -515,7 +515,7 @@ final class TextViewController { } } } - var isAutomaticScrollEnabled = false + var isAutomaticScrollEnabled = true var hasPendingFullLayout = false var preserveUndoStackWhenSettingString = false private(set) var maximumLeadingCharacterPairComponentLength = 0 From 68d1979f51c0bf88949459d85888a5e0ddf9120c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 21:25:54 +0100 Subject: [PATCH 082/232] Removes unneeded pragmas --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 2 -- .../TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift | 2 -- 2 files changed, 4 deletions(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index f5840c1b8..a60ee9fbd 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -1298,11 +1298,9 @@ extension TextView: HighlightNavigationControllerDelegate { scrollRangeToVisible(range) selectedTextRange = IndexedRange(range) _ = becomeFirstResponder() - #if os(iOS) if showMenuAfterNavigatingToHighlightedRange { editMenuController.presentEditMenu(from: self, forTextIn: range) } - #endif switch highlightNavigationRange.loopMode { case .previousGoesToLast: editorDelegate?.textViewDidLoopToLastHighlightedRange(self) diff --git a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift index ba4b6e495..6f4d02c56 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift @@ -51,7 +51,6 @@ final class UITextSearchingHelper: NSObject { } } -#if compiler(>=5.7) @available(iOS 16, *) extension UITextSearchingHelper: UITextSearching { var supportsTextReplacement: Bool { @@ -199,4 +198,3 @@ private extension SearchQuery.MatchMethod { } } #endif -#endif From 27cede5b47a8af45de0fe12ae77b9de0aeeb5f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 21:26:14 +0100 Subject: [PATCH 083/232] Ensures scroll view is scrolled automatically --- .../TextView/Core/Mac/TextView_Mac.swift | 10 +++++++- .../TextViewController+UndoRedo.swift | 1 - .../TextViewController.swift | 7 +----- .../Core/iOS/TextView_iOS+UITextInput.swift | 3 --- .../TextView/Core/iOS/TextView_iOS.swift | 24 ++++++++++++------- 5 files changed, 26 insertions(+), 19 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index fc8b20ecd..18e4eee4b 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -627,7 +627,7 @@ private extension TextView { } } -// MARK: - Scroll Bounds +// MARK: - Scrolling private extension TextView { private func setupScrollViewBoundsDidChangeObserver() { NotificationCenter.default.addObserver( @@ -642,6 +642,12 @@ private extension TextView { textViewController.viewport = CGRect(origin: scrollView.contentOffset, size: frame.size) textViewController.layoutIfNeeded() } + + private func scrollToVisibleLocationIfNeeded() { + if isAutomaticScrollEnabled, let newRange = textViewController.selectedRange, newRange.length == 0 { + textViewController.scrollLocationToVisible(newRange.location) + } + } } // MARK: - Caret @@ -672,8 +678,10 @@ extension TextView: TextViewControllerDelegate { } func textViewController(_ textViewController: TextViewController, didUpdateSelectedRange selectedRange: NSRange?) { + layoutIfNeeded() caretView.delayBlinkIfNeeded() updateCaretFrame() + scrollToVisibleLocationIfNeeded() } } #endif diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift index c23ec27ba..a6f0c97fa 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift @@ -17,7 +17,6 @@ extension TextViewController { textViewController.replaceText(in: range, with: text) textViewController.selectedRange = oldSelectedRange #if os(iOS) - textViewController.textView.handleTextSelectionChange() textViewController.textView.inputDelegate?.selectionDidChange(textViewController.textView) #endif } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 7fd504b71..0affb01c0 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -33,10 +33,8 @@ final class TextViewController { if selectedRange != oldValue { layoutManager.selectedRange = selectedRange layoutManager.setNeedsLayoutLineSelection() + highlightNavigationController.selectedRange = selectedRange textView.setNeedsLayout() - #if os(macOS) - textView.layoutIfNeeded() - #endif delegate?.textViewController(self, didUpdateSelectedRange: selectedRange) } } @@ -63,9 +61,6 @@ final class TextViewController { if isSelectable != oldValue && !isSelectable && isEditing { textView.resignFirstResponder() selectedRange = nil - #if os(iOS) - textView.handleTextSelectionChange() - #endif isEditing = false textView.editorDelegate?.textViewDidEndEditing(textView) } diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index f1c465f11..92c148114 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -91,7 +91,6 @@ public extension TextView { return } textViewController.replaceText(in: indexedRange.range.nonNegativeLength, with: preparedText) - handleTextSelectionChange() } func insertText(_ text: String) { @@ -115,7 +114,6 @@ public extension TextView { textViewController.replaceText(in: selectedRange, with: preparedText) } layoutIfNeeded() - handleTextSelectionChange() } func deleteBackward() { @@ -161,7 +159,6 @@ public extension TextView { if isDeletingMultipleCharacters { undoManager?.endUndoGrouping() } - handleTextSelectionChange() } } diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index a60ee9fbd..ac0dc51ed 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -55,6 +55,9 @@ open class TextView: UIScrollView { set { if newValue != isSelectable { textViewController.isSelectable = newValue + if !isSelectable && isEditing { + handleTextSelectionChange() + } if !newValue { installNonEditableInteraction() } @@ -1023,12 +1026,9 @@ extension TextView { } } - func handleTextSelectionChange() { + private func handleTextSelectionChange() { UIMenuController.shared.hideMenu(from: self) - textViewController.highlightNavigationController.selectedRange = textViewController.selectedRange - if isAutomaticScrollEnabled, let newRange = textViewController.selectedRange, newRange.length == 0 { - textViewController.scrollLocationToVisible(newRange.location) - } + scrollToVisibleLocationIfNeeded() editorDelegate?.textViewDidChangeSelection(self) } @@ -1183,6 +1183,12 @@ private extension TextView { break } } + + private func scrollToVisibleLocationIfNeeded() { + if isAutomaticScrollEnabled, let newRange = textViewController.selectedRange, newRange.length == 0 { + textViewController.scrollLocationToVisible(newRange.location) + } + } } // MARK: - TextViewControllerDelegate @@ -1190,6 +1196,10 @@ extension TextView: TextViewControllerDelegate { func textViewControllerDidChangeText(_ textViewController: TextViewController) { editorDelegate?.textViewDidChange(self) } + + func textViewController(_ textViewController: TextViewController, didUpdateSelectedRange selectedRange: NSRange?) { + handleTextSelectionChange() + } } // MARK: - SearchControllerDelegate @@ -1230,9 +1240,7 @@ extension TextView: KeyboardObserverDelegate { keyboardWillShowWithHeight keyboardHeight: CGFloat, animation: KeyboardObserver.Animation? ) { - if isAutomaticScrollEnabled, let newRange = textViewController.selectedRange, newRange.length == 0 { - textViewController.scrollRangeToVisible(newRange) - } + scrollToVisibleLocationIfNeeded() } } From 409b3a6ee97a3e21a03aadae1ed6cc53f7efaf4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 21:37:41 +0100 Subject: [PATCH 084/232] Takes textContainerInset into account when scrolling to range --- .../TextViewController+Scrolling.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift index dc4bdf25f..c44933b59 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift @@ -36,10 +36,17 @@ private extension TextViewController { private func contentOffsetForScrollingToVisibleRect(_ rect: CGRect) -> CGPoint { // Create the viewport: a rectangle containing the content that is visible to the user. var viewport = CGRect(origin: scrollView.contentOffset, size: textView.frame.size) - viewport.origin.y += scrollView.adjustedContentInset.top - viewport.origin.x += scrollView.adjustedContentInset.left + gutterWidth - viewport.size.width -= scrollView.adjustedContentInset.left + scrollView.adjustedContentInset.right + gutterWidth - viewport.size.height -= scrollView.adjustedContentInset.top + scrollView.adjustedContentInset.bottom + viewport.origin.y += scrollView.adjustedContentInset.top + textContainerInset.top + viewport.origin.x += scrollView.adjustedContentInset.left + gutterWidth + textContainerInset.left + viewport.size.width -= scrollView.adjustedContentInset.left + + scrollView.adjustedContentInset.right + + gutterWidth + + textContainerInset.left + + textContainerInset.right + viewport.size.height -= scrollView.adjustedContentInset.top + + scrollView.adjustedContentInset.bottom + + textContainerInset.top + + textContainerInset.bottom // Construct the best possible content offset. var newContentOffset = scrollView.contentOffset if rect.minX < viewport.minX { From 46e4c3234c11c2b47d5d2d7907db738fa8decd4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 1 Feb 2023 21:43:20 +0100 Subject: [PATCH 085/232] Adds large transparent title bar --- Example/MacExample/Base.lproj/Main.storyboard | 11 ++++++++++- Example/MacExample/MainViewController.swift | 8 +++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Example/MacExample/Base.lproj/Main.storyboard b/Example/MacExample/Base.lproj/Main.storyboard index 36cc0b05d..d83acec12 100644 --- a/Example/MacExample/Base.lproj/Main.storyboard +++ b/Example/MacExample/Base.lproj/Main.storyboard @@ -685,11 +685,20 @@ - + + + + + + + + + + diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index d71d7579c..3ec164ffd 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -6,6 +6,7 @@ import RunestoneTomorrowNightTheme import RunestoneTomorrowTheme final class MainViewController: NSViewController { + private let theme: EditorTheme = OneDarkTheme() private let textView: TextView = { let this = TextView() this.translatesAutoresizingMaskIntoConstraints = false @@ -31,7 +32,12 @@ final class MainViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() setupTextView() - applyTheme(OneDarkTheme()) + applyTheme(theme) + } + + override func viewDidAppear() { + super.viewDidAppear() + view.window?.backgroundColor = theme.backgroundColor } } From cd2a6c58fdf80c2dff0fd79488eccf123f600d51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 2 Feb 2023 09:08:25 +0100 Subject: [PATCH 086/232] Fixes failing UI tests --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index ac0dc51ed..b45308333 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -605,6 +605,7 @@ open class TextView: UIScrollView { // 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 notifyInputDelegateAboutSelectionChangeInLayoutSubviews { + notifyInputDelegateAboutSelectionChangeInLayoutSubviews = false inputDelegate?.selectionWillChange(self) inputDelegate?.selectionDidChange(self) } From a30946d67092f1173512e2a233df5ad2e0e7a099 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 2 Feb 2023 13:19:16 +0100 Subject: [PATCH 087/232] Fixes incorrect content size --- Sources/Runestone/TextView/Core/ContentSizeService.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Runestone/TextView/Core/ContentSizeService.swift b/Sources/Runestone/TextView/Core/ContentSizeService.swift index eec110123..807ed652c 100644 --- a/Sources/Runestone/TextView/Core/ContentSizeService.swift +++ b/Sources/Runestone/TextView/Core/ContentSizeService.swift @@ -146,14 +146,17 @@ final class ContentSizeService { if line.id == lineIDTrackingWidth || lineWidth > maximumLineWidth { self.lineIDTrackingWidth = line.id _longestLineWidth = nil + isContentSizeInvalid = true } } else if !isLineWrappingEnabled { _longestLineWidth = nil + isContentSizeInvalid = true } } let didUpdateHeight = lineManager.setHeight(of: line, to: newSize.height) if didUpdateHeight { _totalLinesHeight = nil + isContentSizeInvalid = true } } } @@ -167,6 +170,7 @@ private extension ContentSizeService { lineWidths[longestLine.id] = lineController.lineWidth if !isLineWrappingEnabled { _longestLineWidth = nil + isContentSizeInvalid = true } } } From 1e2ecf5532ad2f2499b91a322e1345c34dc0944e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 2 Feb 2023 13:19:35 +0100 Subject: [PATCH 088/232] Improves formatting --- .../TextView/Core/ContentSizeService.swift | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/Sources/Runestone/TextView/Core/ContentSizeService.swift b/Sources/Runestone/TextView/Core/ContentSizeService.swift index 807ed652c..cc77e204c 100644 --- a/Sources/Runestone/TextView/Core/ContentSizeService.swift +++ b/Sources/Runestone/TextView/Core/ContentSizeService.swift @@ -45,10 +45,10 @@ final class ContentSizeService { } } var contentHeight: CGFloat { - return ceil(totalLinesHeight + textContainerInset.top + textContainerInset.bottom) + ceil(totalLinesHeight + textContainerInset.top + textContainerInset.bottom) } var contentSize: CGSize { - return CGSize(width: contentWidth, height: contentHeight) + CGSize(width: contentWidth, height: contentHeight) } @Published private(set) var isContentSizeInvalid = false @@ -114,10 +114,12 @@ final class ContentSizeService { } } - init(lineManager: LineManager, - lineControllerStorage: LineControllerStorage, - gutterWidthService: GutterWidthService, - invisibleCharacterConfiguration: InvisibleCharacterConfiguration) { + init( + lineManager: LineManager, + lineControllerStorage: LineControllerStorage, + gutterWidthService: GutterWidthService, + invisibleCharacterConfiguration: InvisibleCharacterConfiguration + ) { self.lineManager = lineManager self.lineControllerStorage = lineControllerStorage self.gutterWidthService = gutterWidthService @@ -163,15 +165,16 @@ final class ContentSizeService { private extension ContentSizeService { private func storeWidthOfInitiallyLongestLine() { - if let longestLine = lineManager.initialLongestLine { - lineIDTrackingWidth = longestLine.id - let lineController = lineControllerStorage.getOrCreateLineController(for: longestLine) - lineController.invalidateEverything() - lineWidths[longestLine.id] = lineController.lineWidth - if !isLineWrappingEnabled { - _longestLineWidth = nil - isContentSizeInvalid = true - } + guard let longestLine = lineManager.initialLongestLine else { + return + } + lineIDTrackingWidth = longestLine.id + let lineController = lineControllerStorage.getOrCreateLineController(for: longestLine) + lineController.invalidateEverything() + lineWidths[longestLine.id] = lineController.lineWidth + if !isLineWrappingEnabled { + _longestLineWidth = nil + isContentSizeInvalid = true } } } From 6890bb5a32a6d041d3d43e89968802866bbe88aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 2 Feb 2023 13:20:30 +0100 Subject: [PATCH 089/232] Removes toolbar --- Example/MacExample/Base.lproj/Main.storyboard | 9 --------- 1 file changed, 9 deletions(-) diff --git a/Example/MacExample/Base.lproj/Main.storyboard b/Example/MacExample/Base.lproj/Main.storyboard index d83acec12..6437e2a4b 100644 --- a/Example/MacExample/Base.lproj/Main.storyboard +++ b/Example/MacExample/Base.lproj/Main.storyboard @@ -690,15 +690,6 @@ - - - - - - - - - From fdb35528de658a722ea555c768c3f6729a564683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 2 Feb 2023 13:42:48 +0100 Subject: [PATCH 090/232] Ensures scrollers are on top --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 18e4eee4b..b7dd5025c 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -430,12 +430,14 @@ open class TextView: NSView { scrollView.contentView.postsBoundsChangedNotifications = true scrollContentView.addSubview(textViewController.layoutManager.linesContainerView) scrollContentView.addSubview(caretView) - scrollView.addSubview(textViewController.layoutManager.gutterContainerView, positioned: .below, relativeTo: scrollView.horizontalScroller) + scrollView.addSubview(textViewController.layoutManager.gutterContainerView) addSubview(textViewController.layoutManager.lineSelectionBackgroundView) addSubview(scrollView) setNeedsLayout() setupWindowObservers() setupScrollViewBoundsDidChangeObserver() + scrollView.horizontalScroller?.layer?.zPosition = 1000 + scrollView.verticalScroller?.layer?.zPosition = 1000 } required public init?(coder: NSCoder) { From e776fa40bd85e07bbff7db0c4d3f899a9a084172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 2 Feb 2023 13:44:47 +0100 Subject: [PATCH 091/232] Adds background to title bar --- Example/MacExample/Base.lproj/Main.storyboard | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/MacExample/Base.lproj/Main.storyboard b/Example/MacExample/Base.lproj/Main.storyboard index 6437e2a4b..36cc0b05d 100644 --- a/Example/MacExample/Base.lproj/Main.storyboard +++ b/Example/MacExample/Base.lproj/Main.storyboard @@ -685,7 +685,7 @@ - + From a124c4a7c0bf0880047f2e11d51da4a4608f5bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 09:32:32 +0100 Subject: [PATCH 092/232] Moves contentSize to ContentSizeService --- .../TextView/Core/ContentSizeService.swift | 30 +++++++++++++++---- .../TextView/Core/Mac/TextView_Mac.swift | 3 +- .../TextViewController+ContentSize.swift | 13 ++------ .../TextViewController.swift | 28 ++++++++++------- .../TextView/Core/iOS/TextView_iOS.swift | 5 +--- .../TextSelection/SelectionRectService.swift | 2 +- 6 files changed, 47 insertions(+), 34 deletions(-) diff --git a/Sources/Runestone/TextView/Core/ContentSizeService.swift b/Sources/Runestone/TextView/Core/ContentSizeService.swift index cc77e204c..8d3c20a35 100644 --- a/Sources/Runestone/TextView/Core/ContentSizeService.swift +++ b/Sources/Runestone/TextView/Core/ContentSizeService.swift @@ -3,14 +3,14 @@ import Foundation final class ContentSizeService { var safeAreaInset: MultiPlatformEdgeInsets = .zero - var textContainerInset: MultiPlatformEdgeInsets = .zero - var scrollViewWidth: CGFloat = 0 { + var scrollViewSize: CGSize = .zero { didSet { - if scrollViewWidth != oldValue && isLineWrappingEnabled { + if scrollViewSize != oldValue && isLineWrappingEnabled { invalidateContentSize() } } } + var textContainerInset: MultiPlatformEdgeInsets = .zero var isLineWrappingEnabled = true { didSet { if isLineWrappingEnabled != oldValue { @@ -18,6 +18,20 @@ final class ContentSizeService { } } } + var horizontalOverscrollFactor: CGFloat = 0 { + didSet { + if horizontalOverscrollFactor != oldValue && !isLineWrappingEnabled { + invalidateContentSize() + } + } + } + var verticalOverscrollFactor: CGFloat = 0 { + didSet { + if verticalOverscrollFactor != oldValue { + invalidateContentSize() + } + } + } let invisibleCharacterConfiguration: InvisibleCharacterConfiguration var lineManager: LineManager { didSet { @@ -29,11 +43,11 @@ final class ContentSizeService { } } var contentWidth: CGFloat { - let minimumWidth = scrollViewWidth - safeAreaInset.left - safeAreaInset.right + let minimumWidth = scrollViewSize.width - safeAreaInset.left - safeAreaInset.right if isLineWrappingEnabled { return minimumWidth } else { - let textContentWidth = longestLineWidth ?? scrollViewWidth + let textContentWidth = longestLineWidth ?? scrollViewSize.width let preferredWidth = ceil( textContentWidth + gutterWidthService.gutterWidth @@ -48,7 +62,11 @@ final class ContentSizeService { ceil(totalLinesHeight + textContainerInset.top + textContainerInset.bottom) } var contentSize: CGSize { - CGSize(width: contentWidth, height: contentHeight) + let horizontalOverscrollLength = max(scrollViewSize.width * horizontalOverscrollFactor, 0) + let verticalOverscrollLength = max(scrollViewSize.height * verticalOverscrollFactor, 0) + let width = contentWidth + (isLineWrappingEnabled ? 0 : horizontalOverscrollLength) + let height = contentHeight + verticalOverscrollLength + return CGSize(width: width, height: height) } @Published private(set) var isContentSizeInvalid = false diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index b7dd5025c..b2a778664 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -31,7 +31,6 @@ open class TextView: NSView { } set { textViewController.text = newValue - scrollView.contentSize = textViewController.contentSize } } /// Colors and fonts to be used by the editor. @@ -482,7 +481,7 @@ open class TextView: NSView { super.resizeSubviews(withOldSize: oldSize) scrollView.frame = bounds textViewController.viewport = CGRect(origin: scrollView.contentOffset, size: frame.size) - textViewController.scrollViewWidth = scrollView.frame.width + textViewController.scrollViewSize = scrollView.frame.size textViewController.layoutIfNeeded() textViewController.handleContentSizeUpdateIfNeeded() updateCaretFrame() diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift index a6259e679..c58357f30 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift @@ -1,17 +1,8 @@ import Foundation extension TextViewController { - var contentSize: CGSize { - let horizontalOverscrollLength = max(textView.frame.width * horizontalOverscrollFactor, 0) - let verticalOverscrollLength = max(textView.frame.height * verticalOverscrollFactor, 0) - let baseContentSize = contentSizeService.contentSize - let width = isLineWrappingEnabled ? baseContentSize.width : baseContentSize.width + horizontalOverscrollLength - let height = baseContentSize.height + verticalOverscrollLength - return CGSize(width: width, height: height) - } - func invalidateContentSizeIfNeeded() { - if scrollView.contentSize != contentSize { + if scrollView.contentSize != contentSizeService.contentSize { hasPendingContentSizeUpdate = true handleContentSizeUpdateIfNeeded() } @@ -37,7 +28,7 @@ extension TextViewController { } hasPendingContentSizeUpdate = false let oldContentOffset = scrollView.contentOffset - scrollView.contentSize = contentSize + scrollView.contentSize = contentSizeService.contentSize scrollView.contentOffset = oldContentOffset textView.setNeedsLayout() } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 0affb01c0..15d7d5edd 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -109,11 +109,11 @@ final class TextViewController { } } var hasPendingContentSizeUpdate = false - var scrollViewWidth: CGFloat = 0 { + var scrollViewSize: CGSize = .zero { didSet { - if scrollViewWidth != oldValue { - contentSizeService.scrollViewWidth = scrollViewWidth - layoutManager.scrollViewWidth = scrollViewWidth + if scrollViewSize != oldValue { + contentSizeService.scrollViewSize = scrollViewSize + layoutManager.scrollViewWidth = scrollViewSize.width if isLineWrappingEnabled { invalidateLines() } @@ -463,16 +463,24 @@ final class TextViewController { } } } - var verticalOverscrollFactor: CGFloat = 0 { - didSet { - if verticalOverscrollFactor != oldValue { + var verticalOverscrollFactor: CGFloat { + get { + return contentSizeService.verticalOverscrollFactor + } + set { + if newValue != contentSizeService.verticalOverscrollFactor { + contentSizeService.verticalOverscrollFactor = newValue invalidateContentSizeIfNeeded() } } } - var horizontalOverscrollFactor: CGFloat = 0 { - didSet { - if horizontalOverscrollFactor != oldValue { + var horizontalOverscrollFactor: CGFloat { + get { + return contentSizeService.horizontalOverscrollFactor + } + set { + if newValue != contentSizeService.horizontalOverscrollFactor { + contentSizeService.horizontalOverscrollFactor = newValue invalidateContentSizeIfNeeded() } } diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index b45308333..cda1e4251 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -30,7 +30,6 @@ open class TextView: UIScrollView { } set { textViewController.text = newValue - contentSize = textViewController.contentSize } } /// A Boolean value that indicates whether the text view is editable. @@ -599,7 +598,7 @@ open class TextView: UIScrollView { open override func layoutSubviews() { super.layoutSubviews() hasDeletedTextWithPendingLayoutSubviews = false - textViewController.scrollViewWidth = frame.width + textViewController.scrollViewSize = frame.size textViewController.layoutIfNeeded() // 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. @@ -622,7 +621,6 @@ open class TextView: UIScrollView { override open func safeAreaInsetsDidChange() { super.safeAreaInsetsDidChange() textViewController.safeAreaInsets = safeAreaInsets - contentSize = textViewController.contentSize layoutIfNeeded() } @@ -758,7 +756,6 @@ open class TextView: UIScrollView { /// - Parameter addUndoAction: Whether the state change can be undone. Defaults to false. public func setState(_ state: TextViewState, addUndoAction: Bool = false) { textViewController.setState(state, addUndoAction: addUndoAction) - contentSize = textViewController.contentSize } /// Returns the row and column at the specified location in the text. diff --git a/Sources/Runestone/TextView/TextSelection/SelectionRectService.swift b/Sources/Runestone/TextView/TextSelection/SelectionRectService.swift index 3d1ba731c..1630fb244 100644 --- a/Sources/Runestone/TextView/TextSelection/SelectionRectService.swift +++ b/Sources/Runestone/TextView/TextSelection/SelectionRectService.swift @@ -32,7 +32,7 @@ final class SelectionRectService { let adjustedRange = NSRange(location: range.location, length: selectsLineEnding ? range.length - 1 : range.length) let startCaretRect = caretRectService.caretRect(at: adjustedRange.lowerBound, allowMovingCaretToNextLineFragment: true) let endCaretRect = caretRectService.caretRect(at: adjustedRange.upperBound, allowMovingCaretToNextLineFragment: false) - let fullWidth = max(contentSizeService.contentWidth, contentSizeService.scrollViewWidth) - leadingLineSpacing - textContainerInset.right + let fullWidth = max(contentSizeService.contentWidth, contentSizeService.scrollViewSize.width) - leadingLineSpacing - textContainerInset.right if startCaretRect.minY == endCaretRect.minY && startCaretRect.maxY == endCaretRect.maxY { // Selecting text in the same line fragment. let width = selectsLineEnding ? fullWidth - (startCaretRect.minX - leadingLineSpacing) : endCaretRect.maxX - startCaretRect.maxX From 23d7daacf8706d85a414b476ae2e3525326c7b66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 09:32:37 +0100 Subject: [PATCH 093/232] Forces dark mode --- Example/MacExample/MainViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index 3ec164ffd..97c7cefb5 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -31,6 +31,7 @@ final class MainViewController: NSViewController { override func viewDidLoad() { super.viewDidLoad() + view.appearance = NSAppearance(named: .vibrantDark) setupTextView() applyTheme(theme) } From 39d207694fa5ae6d7b872f031ba74e56f25484f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 10:32:29 +0100 Subject: [PATCH 094/232] Disables scrollers until they are needed --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 4 ---- .../TextViewController/TextViewController+ContentSize.swift | 6 ++++++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index b2a778664..9d9ed66ea 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -423,8 +423,6 @@ open class TextView: NSView { textViewController.selectedRange = NSRange(location: 0, length: 0) scrollView.borderType = .noBorder scrollView.drawsBackground = false - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = true scrollView.documentView = scrollContentView scrollView.contentView.postsBoundsChangedNotifications = true scrollContentView.addSubview(textViewController.layoutManager.linesContainerView) @@ -435,8 +433,6 @@ open class TextView: NSView { setNeedsLayout() setupWindowObservers() setupScrollViewBoundsDidChangeObserver() - scrollView.horizontalScroller?.layer?.zPosition = 1000 - scrollView.verticalScroller?.layer?.zPosition = 1000 } required public init?(coder: NSCoder) { diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift index c58357f30..300bcf7bd 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift @@ -31,5 +31,11 @@ extension TextViewController { scrollView.contentSize = contentSizeService.contentSize scrollView.contentOffset = oldContentOffset textView.setNeedsLayout() + #if os(macOS) + scrollView.hasVerticalScroller = scrollView.contentSize.height > scrollView.frame.height + scrollView.hasHorizontalScroller = scrollView.contentSize.width > scrollView.frame.width + scrollView.horizontalScroller?.layer?.zPosition = 1000 + scrollView.verticalScroller?.layer?.zPosition = 1000 + #endif } } From 501847548681d39c33812ea1c44597433344de80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 10:57:48 +0100 Subject: [PATCH 095/232] Fixes stringView not being set on tokenizer --- Sources/Runestone/TextView/Core/NavigationService.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/NavigationService.swift b/Sources/Runestone/TextView/Core/NavigationService.swift index 3cdbca94a..f9281c4dc 100644 --- a/Sources/Runestone/TextView/Core/NavigationService.swift +++ b/Sources/Runestone/TextView/Core/NavigationService.swift @@ -18,7 +18,13 @@ final class NavigationService { case backward } - var stringView: StringView + var stringView: StringView { + didSet { + if stringView !== oldValue { + stringTokenizer.stringView = stringView + } + } + } var lineManager: LineManager { get { return lineNavigationService.lineManager From 07952a3f8fb66849d991e8ad56130f6af74f89bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 10:57:54 +0100 Subject: [PATCH 096/232] Adds setState(_:) --- .../Runestone/TextView/Core/Mac/TextView_Mac.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 9d9ed66ea..730a2fcf4 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -500,6 +500,20 @@ open class TextView: NSView { super.keyDown(with: event) } } + + /// Sets the current _state_ of the editor. The state contains the text to be displayed by the editor and + /// various additional information about the text that the editor needs to show the text. + /// + /// It is safe to create an instance of TextViewState in the background, and as such it can be + /// created before presenting the editor to the user, e.g. when opening the document from an instance of + /// UIDocumentBrowserViewController. + /// + /// This is the preferred way to initially set the text, language and theme on the TextView. + /// - Parameter state: The new state to be used by the editor. + /// - Parameter addUndoAction: Whether the state change can be undone. Defaults to false. + public func setState(_ state: TextViewState, addUndoAction: Bool = false) { + textViewController.setState(state, addUndoAction: addUndoAction) + } } // MARK: - Commands From b2d93fadcdb29560563fb91f1ba77aad23b0e15e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 10:59:13 +0100 Subject: [PATCH 097/232] Respects indent strategy --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 730a2fcf4..0c8824b94 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -551,7 +551,8 @@ public extension TextView { } override func insertTab(_ sender: Any?) { - textViewController.replaceText(in: textViewController.rangeForInsertingText, with: "\t") + let indentString = indentStrategy.string(indentLevel: 1) + textViewController.replaceText(in: textViewController.rangeForInsertingText, with: indentString) } override func moveLeft(_ sender: Any?) { From 9a538448525b192b8844b41f07910a9ce0c55c9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 10:59:21 +0100 Subject: [PATCH 098/232] Sets indentStrategy --- Example/MacExample/MainViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index 97c7cefb5..fbf6ad6a3 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -22,6 +22,7 @@ final class MainViewController: NSViewController { this.gutterLeadingPadding = 4 this.gutterTrailingPadding = 4 this.isLineWrappingEnabled = false + this.indentStrategy = .space(length: 2) return this }() From 4acc3c3ca9222ed4703d31fb072ebca32aea341c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 10:59:31 +0100 Subject: [PATCH 099/232] Adds syntax highlighting --- Example/MacExample/MainViewController.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index fbf6ad6a3..e05cce556 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -35,6 +35,8 @@ final class MainViewController: NSViewController { view.appearance = NSAppearance(named: .vibrantDark) setupTextView() applyTheme(theme) + let state = TextViewState(text: "", theme: theme, language: .javaScript) + textView.setState(state) } override func viewDidAppear() { From 270cd324493ce8b23eb8432b32b88636c6c21284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 11:37:21 +0100 Subject: [PATCH 100/232] Increases line height --- Example/MacExample/MainViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index e05cce556..ee0600eb3 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -16,7 +16,7 @@ final class MainViewController: NSViewController { this.showSpaces = true this.showLineBreaks = true this.showSoftLineBreaks = true - this.lineHeightMultiplier = 1.2 + this.lineHeightMultiplier = 1.3 this.kern = 0.3 this.lineSelectionDisplayType = .line this.gutterLeadingPadding = 4 From d9df81a427cce1c50bf1cd9f787094fcbbfc108f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 11:37:26 +0100 Subject: [PATCH 101/232] Imports RunestoneJavaScriptLanguage --- Example/MacExample/MainViewController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index ee0600eb3..bcc3cb56c 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -1,5 +1,6 @@ import Cocoa import Runestone +import RunestoneJavaScriptLanguage import RunestoneOneDarkTheme import RunestoneThemeCommon import RunestoneTomorrowNightTheme From fddcf33cb52b4856a20285aaf92357eb1006ec96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 11:37:33 +0100 Subject: [PATCH 102/232] Sets characterPairs --- Example/Example.xcodeproj/project.pbxproj | 6 +++++- Example/MacExample/CharacterPair.swift | 11 +++++++++++ Example/MacExample/MainViewController.swift | 7 +++++++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 Example/MacExample/CharacterPair.swift diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index 6f6f9cbf1..d6d6bbfa4 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 720F5F75298D1B8F00C64EC3 /* CharacterPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = 720F5F74298D1B8F00C64EC3 /* CharacterPair.swift */; }; 721103592989ACDD00DDFE48 /* RunestoneOneDarkTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 721103582989ACDD00DDFE48 /* RunestoneOneDarkTheme */; }; 7211035B2989ACDD00DDFE48 /* RunestonePlainTextTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 7211035A2989ACDD00DDFE48 /* RunestonePlainTextTheme */; }; 7211035D2989ACDD00DDFE48 /* RunestoneThemeCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 7211035C2989ACDD00DDFE48 /* RunestoneThemeCommon */; }; @@ -42,6 +43,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 720F5F74298D1B8F00C64EC3 /* CharacterPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterPair.swift; sourceTree = ""; }; 7216EAC62829A16C001B6D39 /* Themes */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Themes; sourceTree = ""; }; 7243F9BA282D73E9005AAABF /* iOSExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iOSExample.entitlements; sourceTree = ""; }; 729ECE4C2983F5B60049AFF5 /* MacExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -105,11 +107,12 @@ 729ECE4D2983F5B60049AFF5 /* MacExample */ = { isa = PBXGroup; children = ( + 729ECE572983F5B60049AFF5 /* MacExample.entitlements */, 729ECE4E2983F5B60049AFF5 /* AppDelegate.swift */, + 720F5F74298D1B8F00C64EC3 /* CharacterPair.swift */, 729ECE502983F5B60049AFF5 /* MainViewController.swift */, 729ECE522983F5B60049AFF5 /* Assets.xcassets */, 729ECE542983F5B60049AFF5 /* Main.storyboard */, - 729ECE572983F5B60049AFF5 /* MacExample.entitlements */, ); path = MacExample; sourceTree = ""; @@ -369,6 +372,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 720F5F75298D1B8F00C64EC3 /* CharacterPair.swift in Sources */, 729ECE512983F5B60049AFF5 /* MainViewController.swift in Sources */, 729ECE4F2983F5B60049AFF5 /* AppDelegate.swift in Sources */, ); diff --git a/Example/MacExample/CharacterPair.swift b/Example/MacExample/CharacterPair.swift new file mode 100644 index 000000000..a366cfa95 --- /dev/null +++ b/Example/MacExample/CharacterPair.swift @@ -0,0 +1,11 @@ +import Runestone + +final class BasicCharacterPair: CharacterPair { + let leading: String + let trailing: String + + init(leading: String, trailing: String) { + self.leading = leading + self.trailing = trailing + } +} diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index bcc3cb56c..4e7b0a251 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -24,6 +24,13 @@ final class MainViewController: NSViewController { this.gutterTrailingPadding = 4 this.isLineWrappingEnabled = false this.indentStrategy = .space(length: 2) + this.characterPairs = [ + BasicCharacterPair(leading: "(", trailing: ")"), + BasicCharacterPair(leading: "{", trailing: "}"), + BasicCharacterPair(leading: "[", trailing: "]"), + BasicCharacterPair(leading: "\"", trailing: "\""), + BasicCharacterPair(leading: "'", trailing: "'") + ] return this }() From 1fac27ddf16e8cb20d80bdf4cb81943a69067732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 3 Feb 2023 14:43:32 +0100 Subject: [PATCH 103/232] Calls shouldChangeText(in:replacementText:) --- .../Core/Mac/TextView_Mac+NSTextInputClient.swift | 7 +++---- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 8 ++++++-- Sources/Runestone/TextView/Indent/IndentController.swift | 3 +-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift index 03536ee69..b45350761 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift @@ -13,10 +13,9 @@ extension TextView: NSTextInputClient { guard let string = string as? String else { return } - if replacementRange.location == NSNotFound { - textViewController.replaceText(in: textViewController.rangeForInsertingText, with: string) - } else { - textViewController.replaceText(in: replacementRange, with: string) + let range = replacementRange.location == NSNotFound ? textViewController.rangeForInsertingText : replacementRange + if textViewController.shouldChangeText(in: range, replacementText: string) { + textViewController.replaceText(in: range, with: string) } } diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 0c8824b94..5548be987 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -547,12 +547,16 @@ public extension TextView { } override func insertNewline(_ sender: Any?) { - textViewController.indentController.insertLineBreak(in: textViewController.rangeForInsertingText, using: lineEndings) + if textViewController.shouldChangeText(in: textViewController.rangeForInsertingText, replacementText: lineEndings.symbol) { + textViewController.indentController.insertLineBreak(in: textViewController.rangeForInsertingText, using: lineEndings.symbol) + } } override func insertTab(_ sender: Any?) { let indentString = indentStrategy.string(indentLevel: 1) - textViewController.replaceText(in: textViewController.rangeForInsertingText, with: indentString) + if textViewController.shouldChangeText(in: textViewController.rangeForInsertingText, replacementText: indentString) { + textViewController.replaceText(in: textViewController.rangeForInsertingText, with: indentString) + } } override func moveLeft(_ sender: Any?) { diff --git a/Sources/Runestone/TextView/Indent/IndentController.swift b/Sources/Runestone/TextView/Indent/IndentController.swift index 24fc86c54..ecd2326ef 100644 --- a/Sources/Runestone/TextView/Indent/IndentController.swift +++ b/Sources/Runestone/TextView/Indent/IndentController.swift @@ -122,8 +122,7 @@ final class IndentController { } } - func insertLineBreak(in range: NSRange, using lineEnding: LineEnding) { - let symbol = lineEnding.symbol + func insertLineBreak(in range: NSRange, using symbol: String) { if let startLinePosition = lineManager.linePosition(at: range.lowerBound), let endLinePosition = lineManager.linePosition(at: range.upperBound) { let strategy = languageMode.strategyForInsertingLineBreak(from: startLinePosition, to: endLinePosition, using: indentStrategy) From 780cb20fc6102da0bfc0e79072adefefa502f9e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 4 Feb 2023 18:28:45 +0100 Subject: [PATCH 104/232] Passes symbol --- .../Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index 92c148114..509d5a3bc 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -109,7 +109,7 @@ public extension TextView { // The backtick will remain marked unless we manually clear the marked range. textViewController.markedRange = nil if LineEnding(symbol: text) != nil { - textViewController.indentController.insertLineBreak(in: selectedRange, using: lineEndings) + textViewController.indentController.insertLineBreak(in: selectedRange, using: lineEndings.symbol) } else { textViewController.replaceText(in: selectedRange, with: preparedText) } From 47ff24d3393fca9aa9ec4a6dc22f0de1639e8766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 5 Feb 2023 11:07:14 +0100 Subject: [PATCH 105/232] Renames SelectionRectService to SelectionRectFactory --- .../TextView/Core/LayoutManager.swift | 3 --- .../TextViewController.swift | 11 --------- .../Core/iOS/TextView_iOS+UITextInput.swift | 15 +++++++++--- ...rvice.swift => SelectionRectFactory.swift} | 23 +++++++++++-------- 4 files changed, 25 insertions(+), 27 deletions(-) rename Sources/Runestone/TextView/TextSelection/{SelectionRectService.swift => SelectionRectFactory.swift} (88%) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index 008f7b247..496cb9b13 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -122,7 +122,6 @@ final class LayoutManager { private let contentSizeService: ContentSizeService private let gutterWidthService: GutterWidthService private let caretRectService: CaretRectService - private let selectionRectService: SelectionRectService private let highlightService: HighlightService // MARK: - Rendering @@ -139,7 +138,6 @@ final class LayoutManager { contentSizeService: ContentSizeService, gutterWidthService: GutterWidthService, caretRectService: CaretRectService, - selectionRectService: SelectionRectService, highlightService: HighlightService, invisibleCharacterConfiguration: InvisibleCharacterConfiguration ) { @@ -151,7 +149,6 @@ final class LayoutManager { self.contentSizeService = contentSizeService self.gutterWidthService = gutterWidthService self.caretRectService = caretRectService - self.selectionRectService = selectionRectService self.highlightService = highlightService #if os(iOS) self.linesContainerView.isUserInteractionEnabled = false diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 15d7d5edd..baee759d6 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -149,7 +149,6 @@ final class TextViewController { gutterWidthService.lineManager = lineManager contentSizeService.lineManager = lineManager caretRectService.lineManager = lineManager - selectionRectService.lineManager = lineManager navigationService.lineManager = lineManager highlightService.lineManager = lineManager } @@ -161,7 +160,6 @@ final class TextViewController { let gutterWidthService: GutterWidthService let contentSizeService: ContentSizeService let caretRectService: CaretRectService - let selectionRectService: SelectionRectService let navigationService: NavigationService let layoutManager: LayoutManager let indentController: IndentController @@ -376,7 +374,6 @@ final class TextViewController { set { if newValue != layoutManager.textContainerInset { caretRectService.textContainerInset = newValue - selectionRectService.textContainerInset = newValue contentSizeService.textContainerInset = newValue layoutManager.textContainerInset = newValue layoutManager.setNeedsLayout() @@ -414,7 +411,6 @@ final class TextViewController { var lineHeightMultiplier: CGFloat = 1 { didSet { if lineHeightMultiplier != oldValue { - selectionRectService.lineHeightMultiplier = lineHeightMultiplier layoutManager.lineHeightMultiplier = lineHeightMultiplier invalidateLines() lineManager.estimatedLineHeight = estimatedLineHeight @@ -555,12 +551,6 @@ final class TextViewController { lineControllerStorage: lineControllerStorage, gutterWidthService: gutterWidthService ) - selectionRectService = SelectionRectService( - lineManager: lineManager, - contentSizeService: contentSizeService, - gutterWidthService: gutterWidthService, - caretRectService: caretRectService - ) navigationService = NavigationService( stringView: stringView, lineManager: lineManager, @@ -574,7 +564,6 @@ final class TextViewController { contentSizeService: contentSizeService, gutterWidthService: gutterWidthService, caretRectService: caretRectService, - selectionRectService: selectionRectService, highlightService: highlightService, invisibleCharacterConfiguration: invisibleCharacterConfiguration ) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index 509d5a3bc..bf5f6cd10 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -201,11 +201,20 @@ public extension TextView { } func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { - if let indexedRange = range as? IndexedRange { - return textViewController.selectionRectService.selectionRects(in: indexedRange.range.nonNegativeLength) - } else { + guard let indexedRange = range as? IndexedRange else { return [] } + let selectionRectFactory = SelectionRectFactory( + lineManager: textViewController.lineManager, + contentSizeService: textViewController.contentSizeService, + gutterWidthService: textViewController.gutterWidthService, + caretRectService: textViewController.caretRectService + ) + return selectionRectFactory.selectionRects( + in: indexedRange.range, + textContainerInset: textContainerInset, + lineHeightMultiplier: lineHeightMultiplier + ) } } diff --git a/Sources/Runestone/TextView/TextSelection/SelectionRectService.swift b/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift similarity index 88% rename from Sources/Runestone/TextView/TextSelection/SelectionRectService.swift rename to Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift index 1630fb244..762f8bcf7 100644 --- a/Sources/Runestone/TextView/TextSelection/SelectionRectService.swift +++ b/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift @@ -1,26 +1,29 @@ import CoreGraphics import Foundation -final class SelectionRectService { - var lineManager: LineManager - var textContainerInset: MultiPlatformEdgeInsets = .zero - var lineHeightMultiplier: CGFloat = 1 - +final class SelectionRectFactory { + private let lineManager: LineManager private let contentSizeService: ContentSizeService private let gutterWidthService: GutterWidthService private let caretRectService: CaretRectService - init(lineManager: LineManager, - contentSizeService: ContentSizeService, - gutterWidthService: GutterWidthService, - caretRectService: CaretRectService) { + init( + lineManager: LineManager, + contentSizeService: ContentSizeService, + gutterWidthService: GutterWidthService, + caretRectService: CaretRectService + ) { self.lineManager = lineManager self.contentSizeService = contentSizeService self.gutterWidthService = gutterWidthService self.caretRectService = caretRectService } - func selectionRects(in range: NSRange) -> [TextSelectionRect] { + func selectionRects( + in range: NSRange, + textContainerInset: MultiPlatformEdgeInsets = .zero, + lineHeightMultiplier: CGFloat = 1 + ) -> [TextSelectionRect] { guard range.length > 0 else { return [] } From 00cba6c1c2564f3fe9ef83579d217b766bd67c19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 5 Feb 2023 11:59:09 +0100 Subject: [PATCH 106/232] Removes return statement --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index cda1e4251..ac19630df 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -1282,7 +1282,7 @@ extension TextView: EditMenuControllerDelegate { } func editMenuController(_ controller: EditMenuController, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - return editorDelegate?.textView(self, canReplaceTextIn: highlightedRange) ?? false + editorDelegate?.textView(self, canReplaceTextIn: highlightedRange) ?? false } func editMenuController(_ controller: EditMenuController, highlightedRangeFor range: NSRange) -> HighlightedRange? { From 7b611772de63ff0265c4c217f17e9616b1ae2828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 5 Feb 2023 13:57:55 +0100 Subject: [PATCH 107/232] Fixes incorrect Y-position of selection rects --- Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index ac19630df..da894b9c1 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -615,6 +615,10 @@ open class TextView: UIScrollView { textViewController.handleContentSizeUpdateIfNeeded() textViewController.viewport = CGRect(origin: contentOffset, size: frame.size) textViewController.layoutManager.bringGutterToFront() + // Setting the frame of the text selection view fixes a bug where UIKit assigns an incorrect + // Y-position to the selection rects the first time the user selects text. + // After the initial selection the rectangles would be placed correctly. + textSelectionView?.frame = .zero } /// Called when the safe area of the view changes. From 7dac3df18a2fcbf2ec93b64d5d2ec794154e9bad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 5 Feb 2023 14:51:20 +0100 Subject: [PATCH 108/232] Fixes calls to -textViewDidChangeSelection(_:) --- .../TextView/Core/Mac/TextView_Mac.swift | 2 +- .../TextViewController.swift | 22 ++++++++++++++----- .../Core/iOS/TextView_iOS+UITextInput.swift | 4 ++-- .../TextView/Core/iOS/TextView_iOS.swift | 10 ++++----- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 5548be987..563336b4b 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -693,7 +693,7 @@ extension TextView: TextViewControllerDelegate { updateCaretFrame() } - func textViewController(_ textViewController: TextViewController, didUpdateSelectedRange selectedRange: NSRange?) { + func textViewController(_ textViewController: TextViewController, didChangeSelectedRange selectedRange: NSRange?) { layoutIfNeeded() caretView.delayBlinkIfNeeded() updateCaretFrame() diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index baee759d6..7ac66e267 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -3,11 +3,11 @@ import Foundation protocol TextViewControllerDelegate: AnyObject { func textViewControllerDidChangeText(_ textViewController: TextViewController) - func textViewController(_ textViewController: TextViewController, didUpdateSelectedRange selectedRange: NSRange?) + func textViewController(_ textViewController: TextViewController, didChangeSelectedRange selectedRange: NSRange?) } extension TextViewControllerDelegate { - func textViewController(_ textViewController: TextViewController, didUpdateSelectedRange selectedRange: NSRange?) {} + func textViewController(_ textViewController: TextViewController, didChangeSelectedRange selectedRange: NSRange?) {} } final class TextViewController { @@ -29,13 +29,23 @@ final class TextViewController { private weak var _textView: TextView? private weak var _scrollView: MultiPlatformScrollView? var selectedRange: NSRange? { + get { + return _selectedRange + } + set { + if newValue != _selectedRange { + _selectedRange = newValue + delegate?.textViewController(self, didChangeSelectedRange: newValue) + } + } + } + var _selectedRange: NSRange? { didSet { - if selectedRange != oldValue { - layoutManager.selectedRange = selectedRange + if _selectedRange != oldValue { + layoutManager.selectedRange = _selectedRange layoutManager.setNeedsLayoutLineSelection() - highlightNavigationController.selectedRange = selectedRange + highlightNavigationController.selectedRange = _selectedRange textView.setNeedsLayout() - delegate?.textViewController(self, didUpdateSelectedRange: selectedRange) } } } diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index bf5f6cd10..f51794091 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -192,7 +192,7 @@ public extension TextView { if shouldNotifyInputDelegate { inputDelegate?.selectionWillChange(self) } - textViewController.selectedRange = newRange + textViewController._selectedRange = newRange if shouldNotifyInputDelegate { inputDelegate?.selectionDidChange(self) } @@ -251,7 +251,7 @@ public extension TextView { // The selected range passed to setMarkedText(_:selectedRange:) is local to the marked range. let preferredSelectedRange = NSRange(location: range.location + selectedRange.location, length: selectedRange.length) inputDelegate?.selectionWillChange(self) - textViewController.selectedRange = textViewController.safeSelectionRange(from: preferredSelectedRange) + textViewController._selectedRange = textViewController.safeSelectionRange(from: preferredSelectedRange) inputDelegate?.selectionDidChange(self) removeAndAddEditableTextInteraction() } diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index da894b9c1..cf6a6cf14 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -54,9 +54,6 @@ open class TextView: UIScrollView { set { if newValue != isSelectable { textViewController.isSelectable = newValue - if !isSelectable && isEditing { - handleTextSelectionChange() - } if !newValue { installNonEditableInteraction() } @@ -76,7 +73,6 @@ open class TextView: UIScrollView { set { if newValue != textViewController.selectedRange { textViewController.selectedRange = newValue - handleTextSelectionChange() } } } @@ -1199,8 +1195,10 @@ extension TextView: TextViewControllerDelegate { editorDelegate?.textViewDidChange(self) } - func textViewController(_ textViewController: TextViewController, didUpdateSelectedRange selectedRange: NSRange?) { - handleTextSelectionChange() + func textViewController(_ textViewController: TextViewController, didChangeSelectedRange selectedRange: NSRange?) { + UIMenuController.shared.hideMenu(from: self) + scrollToVisibleLocationIfNeeded() + editorDelegate?.textViewDidChangeSelection(self) } } From 7e50a969f52a9dc20da375f5bb4c42710013aa05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 5 Feb 2023 14:51:56 +0100 Subject: [PATCH 109/232] Adds CaretRectFactory and SelectionRectFactory --- .../TextView/Core/LayoutManager.swift | 14 +++++--- .../TextViewController+Scrolling.swift | 9 +++++- .../TextViewController.swift | 12 ------- .../Core/iOS/TextView_iOS+UITextInput.swift | 25 ++++++++++----- .../TextView/Core/iOS/TextView_iOS.swift | 9 +++++- ...ctService.swift => CaretRectFactory.swift} | 32 ++++++++----------- .../TextSelection/SelectionRectFactory.swift | 28 ++++++++-------- 7 files changed, 71 insertions(+), 58 deletions(-) rename Sources/Runestone/TextView/TextSelection/{CaretRectService.swift => CaretRectFactory.swift} (76%) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index 496cb9b13..c6e750b71 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -121,7 +121,6 @@ final class LayoutManager { } private let contentSizeService: ContentSizeService private let gutterWidthService: GutterWidthService - private let caretRectService: CaretRectService private let highlightService: HighlightService // MARK: - Rendering @@ -137,7 +136,6 @@ final class LayoutManager { lineControllerStorage: LineControllerStorage, contentSizeService: ContentSizeService, gutterWidthService: GutterWidthService, - caretRectService: CaretRectService, highlightService: HighlightService, invisibleCharacterConfiguration: InvisibleCharacterConfiguration ) { @@ -148,7 +146,6 @@ final class LayoutManager { self.lineControllerStorage = lineControllerStorage self.contentSizeService = contentSizeService self.gutterWidthService = gutterWidthService - self.caretRectService = caretRectService self.highlightService = highlightService #if os(iOS) self.linesContainerView.isUserInteractionEnabled = false @@ -363,8 +360,15 @@ extension LayoutManager { let height = (realEndLine.yPosition + realEndLine.data.lineHeight) - minY return CGRect(x: 0, y: textContainerInset.top + minY, width: scrollViewWidth, height: height) case .lineFragment: - let startCaretRect = caretRectService.caretRect(at: selectedRange.lowerBound, allowMovingCaretToNextLineFragment: false) - let endCaretRect = caretRectService.caretRect(at: selectedRange.upperBound, allowMovingCaretToNextLineFragment: false) + let caretRectFactory = CaretRectFactory( + stringView: stringView, + lineManager: lineManager, + lineControllerStorage: lineControllerStorage, + gutterWidthService: gutterWidthService, + textContainerInset: textContainerInset + ) + let startCaretRect = caretRectFactory.caretRect(at: selectedRange.lowerBound, allowMovingCaretToNextLineFragment: false) + let endCaretRect = caretRectFactory.caretRect(at: selectedRange.upperBound, allowMovingCaretToNextLineFragment: false) let startLineFragmentHeight = startCaretRect.height * lineHeightMultiplier let endLineFragmentHeight = endCaretRect.height * lineHeightMultiplier let minY = startCaretRect.minY - (startLineFragmentHeight - startCaretRect.height) / 2 diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift index c44933b59..8c9f4f7c6 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift @@ -25,7 +25,14 @@ private extension TextViewController { } private func caretRect(at location: Int) -> CGRect { - caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: true) + let caretRectFactory = CaretRectFactory( + stringView: stringView, + lineManager: lineManager, + lineControllerStorage: lineControllerStorage, + gutterWidthService: gutterWidthService, + textContainerInset: textContainerInset + ) + return caretRectFactory.caretRect(at: location, allowMovingCaretToNextLineFragment: true) } /// Computes a content offset to scroll to in order to reveal the specified rectangle. diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 7ac66e267..0a06da5dc 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -141,7 +141,6 @@ final class TextViewController { private(set) var stringView = StringView() { didSet { if stringView !== oldValue { - caretRectService.stringView = stringView lineManager.stringView = stringView lineControllerFactory.stringView = stringView lineControllerStorage.stringView = stringView @@ -158,7 +157,6 @@ final class TextViewController { indentController.lineManager = lineManager gutterWidthService.lineManager = lineManager contentSizeService.lineManager = lineManager - caretRectService.lineManager = lineManager navigationService.lineManager = lineManager highlightService.lineManager = lineManager } @@ -169,7 +167,6 @@ final class TextViewController { let lineControllerStorage: LineControllerStorage let gutterWidthService: GutterWidthService let contentSizeService: ContentSizeService - let caretRectService: CaretRectService let navigationService: NavigationService let layoutManager: LayoutManager let indentController: IndentController @@ -205,7 +202,6 @@ final class TextViewController { #if os(iOS) textView.inputDelegate?.selectionWillChange(textView) #endif - caretRectService.showLineNumbers = showLineNumbers gutterWidthService.showLineNumbers = showLineNumbers layoutManager.showLineNumbers = showLineNumbers layoutManager.setNeedsLayout() @@ -383,7 +379,6 @@ final class TextViewController { } set { if newValue != layoutManager.textContainerInset { - caretRectService.textContainerInset = newValue contentSizeService.textContainerInset = newValue layoutManager.textContainerInset = newValue layoutManager.setNeedsLayout() @@ -555,12 +550,6 @@ final class TextViewController { gutterWidthService: gutterWidthService, invisibleCharacterConfiguration: invisibleCharacterConfiguration ) - caretRectService = CaretRectService( - stringView: stringView, - lineManager: lineManager, - lineControllerStorage: lineControllerStorage, - gutterWidthService: gutterWidthService - ) navigationService = NavigationService( stringView: stringView, lineManager: lineManager, @@ -573,7 +562,6 @@ final class TextViewController { lineControllerStorage: lineControllerStorage, contentSizeService: contentSizeService, gutterWidthService: gutterWidthService, - caretRectService: caretRectService, highlightService: highlightService, invisibleCharacterConfiguration: invisibleCharacterConfiguration ) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index f51794091..c18153006 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -24,10 +24,14 @@ public extension TextView { guard let indexedPosition = position as? IndexedPosition else { fatalError("Expected position to be of type \(IndexedPosition.self)") } - return textViewController.caretRectService.caretRect( - at: indexedPosition.index, - allowMovingCaretToNextLineFragment: true + let caretFactory = CaretRectFactory( + stringView: textViewController.stringView, + lineManager: textViewController.lineManager, + lineControllerStorage: textViewController.lineControllerStorage, + gutterWidthService: textViewController.gutterWidthService, + textContainerInset: textContainerInset ) + return caretFactory.caretRect(at: indexedPosition.index, allowMovingCaretToNextLineFragment: true) } func beginFloatingCursor(at point: CGPoint) { @@ -204,17 +208,22 @@ public extension TextView { guard let indexedRange = range as? IndexedRange else { return [] } - let selectionRectFactory = SelectionRectFactory( + let caretRectFactory = CaretRectFactory( + stringView: textViewController.stringView, lineManager: textViewController.lineManager, - contentSizeService: textViewController.contentSizeService, + lineControllerStorage: textViewController.lineControllerStorage, gutterWidthService: textViewController.gutterWidthService, - caretRectService: textViewController.caretRectService + textContainerInset: textContainerInset ) - return selectionRectFactory.selectionRects( - in: indexedRange.range, + let selectionRectFactory = SelectionRectFactory( + lineManager: textViewController.lineManager, + gutterWidthService: textViewController.gutterWidthService, + contentSizeService: textViewController.contentSizeService, + caretRectFactory: caretRectFactory, textContainerInset: textContainerInset, lineHeightMultiplier: lineHeightMultiplier ) + return selectionRectFactory.selectionRects(in: indexedRange.range) } } diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index cf6a6cf14..9188e0e5c 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -1276,7 +1276,14 @@ extension TextView: UITextInteractionDelegate { // MARK: - EditMenuControllerDelegate extension TextView: EditMenuControllerDelegate { func editMenuController(_ controller: EditMenuController, caretRectAt location: Int) -> CGRect { - textViewController.caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: false) + let caretRectFactory = CaretRectFactory( + stringView: textViewController.stringView, + lineManager: textViewController.lineManager, + lineControllerStorage: textViewController.lineControllerStorage, + gutterWidthService: textViewController.gutterWidthService, + textContainerInset: textContainerInset + ) + return caretRectFactory.caretRect(at: location, allowMovingCaretToNextLineFragment: false) } func editMenuControllerShouldReplaceText(_ controller: EditMenuController) { diff --git a/Sources/Runestone/TextView/TextSelection/CaretRectService.swift b/Sources/Runestone/TextView/TextSelection/CaretRectFactory.swift similarity index 76% rename from Sources/Runestone/TextView/TextSelection/CaretRectService.swift rename to Sources/Runestone/TextView/TextSelection/CaretRectFactory.swift index 0a24c75ea..328592b79 100644 --- a/Sources/Runestone/TextView/TextSelection/CaretRectService.swift +++ b/Sources/Runestone/TextView/TextSelection/CaretRectFactory.swift @@ -1,32 +1,28 @@ import CoreGraphics -final class CaretRectService { - var stringView: StringView - var lineManager: LineManager - var textContainerInset: MultiPlatformEdgeInsets = .zero - var showLineNumbers = false - +final class CaretRectFactory { + private let stringView: StringView + private let lineManager: LineManager private let lineControllerStorage: LineControllerStorage private let gutterWidthService: GutterWidthService - private var leadingLineSpacing: CGFloat { - if showLineNumbers { - return gutterWidthService.gutterWidth + textContainerInset.left - } else { - return textContainerInset.left - } - } + private let textContainerInset: MultiPlatformEdgeInsets - init(stringView: StringView, - lineManager: LineManager, - lineControllerStorage: LineControllerStorage, - gutterWidthService: GutterWidthService) { + init( + stringView: StringView, + lineManager: LineManager, + lineControllerStorage: LineControllerStorage, + gutterWidthService: GutterWidthService, + textContainerInset: MultiPlatformEdgeInsets + ) { self.stringView = stringView self.lineManager = lineManager self.lineControllerStorage = lineControllerStorage self.gutterWidthService = gutterWidthService + self.textContainerInset = textContainerInset } func caretRect(at location: Int, allowMovingCaretToNextLineFragment: Bool) -> CGRect { + let leadingLineSpacing = gutterWidthService.gutterWidth + textContainerInset.left let safeLocation = min(max(location, 0), stringView.string.length) let line = lineManager.line(containingCharacterAt: safeLocation)! let lineController = lineControllerStorage.getOrCreateLineController(for: line) @@ -43,7 +39,7 @@ final class CaretRectService { } } -private extension CaretRectService { +private extension CaretRectFactory { private func shouldMoveCaretToNextLineFragment(forLocation location: Int, in line: DocumentLineNode) -> Bool { let lineController = lineControllerStorage.getOrCreateLineController(for: line) guard lineController.numberOfLineFragments > 0 else { diff --git a/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift b/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift index 762f8bcf7..f163ff009 100644 --- a/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift +++ b/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift @@ -3,27 +3,29 @@ import Foundation final class SelectionRectFactory { private let lineManager: LineManager - private let contentSizeService: ContentSizeService private let gutterWidthService: GutterWidthService - private let caretRectService: CaretRectService + private let contentSizeService: ContentSizeService + private let caretRectFactory: CaretRectFactory + private let textContainerInset: MultiPlatformEdgeInsets + private let lineHeightMultiplier: CGFloat init( lineManager: LineManager, - contentSizeService: ContentSizeService, gutterWidthService: GutterWidthService, - caretRectService: CaretRectService + contentSizeService: ContentSizeService, + caretRectFactory: CaretRectFactory, + textContainerInset: MultiPlatformEdgeInsets, + lineHeightMultiplier: CGFloat ) { self.lineManager = lineManager - self.contentSizeService = contentSizeService self.gutterWidthService = gutterWidthService - self.caretRectService = caretRectService + self.contentSizeService = contentSizeService + self.caretRectFactory = caretRectFactory + self.textContainerInset = textContainerInset + self.lineHeightMultiplier = lineHeightMultiplier } - func selectionRects( - in range: NSRange, - textContainerInset: MultiPlatformEdgeInsets = .zero, - lineHeightMultiplier: CGFloat = 1 - ) -> [TextSelectionRect] { + func selectionRects(in range: NSRange) -> [TextSelectionRect] { guard range.length > 0 else { return [] } @@ -33,8 +35,8 @@ final class SelectionRectFactory { let leadingLineSpacing = gutterWidthService.gutterWidth + textContainerInset.left let selectsLineEnding = range.upperBound == endLine.location let adjustedRange = NSRange(location: range.location, length: selectsLineEnding ? range.length - 1 : range.length) - let startCaretRect = caretRectService.caretRect(at: adjustedRange.lowerBound, allowMovingCaretToNextLineFragment: true) - let endCaretRect = caretRectService.caretRect(at: adjustedRange.upperBound, allowMovingCaretToNextLineFragment: false) + let startCaretRect = caretRectFactory.caretRect(at: adjustedRange.lowerBound,allowMovingCaretToNextLineFragment: true) + let endCaretRect = caretRectFactory.caretRect(at: adjustedRange.upperBound,allowMovingCaretToNextLineFragment: false) let fullWidth = max(contentSizeService.contentWidth, contentSizeService.scrollViewSize.width) - leadingLineSpacing - textContainerInset.right if startCaretRect.minY == endCaretRect.minY && startCaretRect.maxY == endCaretRect.maxY { // Selecting text in the same line fragment. From 08402e0265c2650edfeb7cf47332c52d926f3132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 5 Feb 2023 15:10:39 +0100 Subject: [PATCH 110/232] Removes safeSelectionRange(from:) --- Sources/Runestone/Library/NSRange+Helpers.swift | 7 +++++++ .../TextViewController+Editing.swift | 2 +- .../TextViewController+Selection.swift | 10 ---------- .../Core/TextViewController/TextViewController.swift | 2 +- .../TextView/Core/iOS/TextView_iOS+UITextInput.swift | 2 +- 5 files changed, 10 insertions(+), 13 deletions(-) delete mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift diff --git a/Sources/Runestone/Library/NSRange+Helpers.swift b/Sources/Runestone/Library/NSRange+Helpers.swift index 1fc83a725..277528116 100644 --- a/Sources/Runestone/Library/NSRange+Helpers.swift +++ b/Sources/Runestone/Library/NSRange+Helpers.swift @@ -40,6 +40,13 @@ extension NSRange { return NSRange(location: newLowerBound, length: newLength) } + /// Ensures the range fits within a range from zero to the specified length. + /// - Parameter length: The maximum upper bound. + /// - Returns: A range that that fits within zero to the specified length. + func capped(to length: Int) -> NSRange { + capped(to: NSRange(location: 0, length: length)) + } + /// Crates a range that is local to the specified range. /// - Parameter parentRange: The parent range. /// - Returns: A range that is local to the parent range. diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift index af6adcd77..4813f43b0 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift @@ -176,7 +176,7 @@ private extension TextViewController { timedUndoManager.endUndoGrouping() textDidChange() if let oldSelectedRange = oldSelectedRange { - selectedRange = safeSelectionRange(from: oldSelectedRange) + selectedRange = oldSelectedRange.capped(to: stringView.string.length) } } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift deleted file mode 100644 index 4b2973737..000000000 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -extension TextViewController { - func safeSelectionRange(from range: NSRange) -> NSRange { - let stringLength = stringView.string.length - let cappedLocation = min(max(range.location, 0), stringLength) - let cappedLength = min(max(range.length, 0), stringLength - cappedLocation) - return NSRange(location: cappedLocation, length: cappedLength) - } -} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 0a06da5dc..3f1f6c50a 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -102,7 +102,7 @@ final class TextViewController { #if os(iOS) textView.inputDelegate?.selectionWillChange(textView) #endif - selectedRange = safeSelectionRange(from: oldSelectedRange) + selectedRange = oldSelectedRange.capped(to: stringView.string.length) #if os(iOS) textView.inputDelegate?.selectionDidChange(textView) #endif diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index c18153006..68836add1 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -260,7 +260,7 @@ public extension TextView { // The selected range passed to setMarkedText(_:selectedRange:) is local to the marked range. let preferredSelectedRange = NSRange(location: range.location + selectedRange.location, length: selectedRange.length) inputDelegate?.selectionWillChange(self) - textViewController._selectedRange = textViewController.safeSelectionRange(from: preferredSelectedRange) + textViewController._selectedRange = preferredSelectedRange.capped(to: textViewController.stringView.string.length) inputDelegate?.selectionDidChange(self) removeAndAddEditableTextInteraction() } From 7b83fc2928e3e1f37f348fd3b41dbd331173871e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 5 Feb 2023 15:10:54 +0100 Subject: [PATCH 111/232] Uses caretRectFactory --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 563336b4b..4384a25c1 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -669,9 +669,15 @@ private extension TextView { // MARK: - Caret private extension TextView { private func updateCaretFrame() { + let caretRectFactory = CaretRectFactory( + stringView: textViewController.stringView, + lineManager: textViewController.lineManager, + lineControllerStorage: textViewController.lineControllerStorage, + gutterWidthService: textViewController.gutterWidthService, + textContainerInset: textContainerInset + ) let selectedRange = selectedRange() - let caretRect = textViewController.caretRectService.caretRect(at: selectedRange.upperBound, allowMovingCaretToNextLineFragment: true) - caretView.frame = caretRect + caretView.frame = caretRectFactory.caretRect(at: selectedRange.upperBound, allowMovingCaretToNextLineFragment: true) } private func updateCaretVisibility() { From cd2ef12f884456b9793f04d5102f8aefcdea5aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 5 Feb 2023 15:11:09 +0100 Subject: [PATCH 112/232] Only shows caret when selectedRange is not zero --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 4384a25c1..8039dcceb 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -681,7 +681,7 @@ private extension TextView { } private func updateCaretVisibility() { - if isWindowKey && isFirstResponder { + if isWindowKey && isFirstResponder && selectedRange().length == 0 { caretView.isHidden = false caretView.isBlinkingEnabled = true caretView.delayBlinkIfNeeded() From 759696e7fcb00c30c16b000b3178f7ace882e91b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 5 Feb 2023 17:23:16 +0100 Subject: [PATCH 113/232] Removes usage of safeSelectionRange(from:) --- .../TextView/Core/TextViewController/TextViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 3f1f6c50a..5b58332ed 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -609,7 +609,7 @@ final class TextViewController { if let oldSelectedRange = selectedRange { #if os(iOS) textView.inputDelegate?.selectionWillChange(textView) - selectedRange = safeSelectionRange(from: oldSelectedRange) + selectedRange = oldSelectedRange.capped(to: stringView.string.length) textView.inputDelegate?.selectionDidChange(textView) #endif } From 2fce65f6635ba8ab4e567ffc2758e14318317559 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 5 Feb 2023 17:23:38 +0100 Subject: [PATCH 114/232] Hides selected background if selectedRange.length != 0 --- Sources/Runestone/TextView/Core/LayoutManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index c6e750b71..f19cd971f 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -555,7 +555,7 @@ extension LayoutManager { gutterBackgroundView.isHidden = !showLineNumbers lineNumbersContainerView.isHidden = !showLineNumbers gutterSelectionBackgroundView.isHidden = !lineSelectionDisplayType.shouldShowLineSelection || !showLineNumbers || !isEditing - lineSelectionBackgroundView.isHidden = !lineSelectionDisplayType.shouldShowLineSelection || !isEditing || selectedLength > 0 + lineSelectionBackgroundView.isHidden = !lineSelectionDisplayType.shouldShowLineSelection || !isEditing || selectedLength != 0 } } From 557477ca0abf0784eb3e0c0fed5d631a0f454925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 5 Feb 2023 18:36:17 +0100 Subject: [PATCH 115/232] WIP text selection and copy, paste, and cut --- Example/MacExample/MainViewController.swift | 1 + .../TextView/Core/LayoutManager.swift | 2 +- .../TextView/Core/Mac/LineSelectionView.swift | 3 + .../TextView/Core/Mac/SelectionService.swift | 48 +++++ .../Mac/TextView_Mac+NSTextInputClient.swift | 3 +- .../TextView/Core/Mac/TextView_Mac.swift | 199 +++++++++++++++++- .../TextView/Core/NavigationService.swift | 23 +- .../TextView/Core/TextBoundary.swift | 5 + .../TextView/Core/TextDirection.swift | 4 + .../TextView/Core/TextGranularity.swift | 5 + .../TextViewController+Editing.swift | 2 +- .../TextViewController+Navigation.swift | 55 +++-- .../TextViewController+Selection.swift | 69 ++++++ .../TextViewController.swift | 6 + .../Core/iOS/TextView_iOS+UITextInput.swift | 8 +- 15 files changed, 384 insertions(+), 49 deletions(-) create mode 100644 Sources/Runestone/TextView/Core/Mac/LineSelectionView.swift create mode 100644 Sources/Runestone/TextView/Core/Mac/SelectionService.swift create mode 100644 Sources/Runestone/TextView/Core/TextBoundary.swift create mode 100644 Sources/Runestone/TextView/Core/TextDirection.swift create mode 100644 Sources/Runestone/TextView/Core/TextGranularity.swift create mode 100644 Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index 4e7b0a251..d017eb7d8 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -69,5 +69,6 @@ private extension MainViewController { textView.wantsLayer = true textView.layer?.backgroundColor = theme.backgroundColor.cgColor textView.insertionPointColor = theme.textColor + textView.selectionHighlightColor = theme.textColor.withAlphaComponent(0.2) } } diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index f19cd971f..de5b4b581 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -342,7 +342,7 @@ extension LayoutManager { } private func getLineSelectionRect() -> CGRect? { - guard lineSelectionDisplayType.shouldShowLineSelection, var selectedRange = selectedRange else { + guard lineSelectionDisplayType.shouldShowLineSelection, var selectedRange = selectedRange?.nonNegativeLength else { return nil } guard let (startLine, endLine) = lineManager.startAndEndLine(in: selectedRange) else { diff --git a/Sources/Runestone/TextView/Core/Mac/LineSelectionView.swift b/Sources/Runestone/TextView/Core/Mac/LineSelectionView.swift new file mode 100644 index 000000000..aef9491da --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/LineSelectionView.swift @@ -0,0 +1,3 @@ +import Foundation + +final class LineSelectionView: MultiPlatformView, ReusableView {} diff --git a/Sources/Runestone/TextView/Core/Mac/SelectionService.swift b/Sources/Runestone/TextView/Core/Mac/SelectionService.swift new file mode 100644 index 000000000..2abbbb49a --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/SelectionService.swift @@ -0,0 +1,48 @@ +#if os(macOS) +import Foundation + +final class SelectionService { + private let navigationService: NavigationService + private var previouslySelectedRange: NSRange? + + init(navigationService: NavigationService) { + self.navigationService = navigationService + } + + func range(movingFrom currentlySelectedRange: NSRange, by granularity: TextGranularity, inDirection direction: TextDirection) -> NSRange { + let selectedRange = previouslySelectedRange ?? currentlySelectedRange + let newSelectedRange = move(selectedRange, by: granularity, inDirection: direction) + previouslySelectedRange = newSelectedRange + return newSelectedRange + } + + func range(movingFrom currentlySelectedRange: NSRange, toBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> NSRange { + print(previouslySelectedRange) + print(currentlySelectedRange) + let selectedRange = previouslySelectedRange ?? currentlySelectedRange + let newSelectedRange = move(selectedRange, toBoundary: boundary, inDirection: direction) + print(newSelectedRange) + previouslySelectedRange = newSelectedRange + return newSelectedRange + } + + func resetPreviouslySelectedRange() { + previouslySelectedRange = nil + } +} + +private extension SelectionService { + private func move(_ range: NSRange, by granularity: TextGranularity, inDirection directon: TextDirection) -> NSRange { + let offset = directon == .forward ? 1 : -1 + let newUpperBound = navigationService.location(movingFrom: range.upperBound, by: offset, granularity: granularity) + let lengthDiff = newUpperBound - range.upperBound + return NSRange(location: range.location, length: range.length + lengthDiff) + } + + private func move(_ range: NSRange, toBoundary boundary: TextBoundary, inDirection directon: TextDirection) -> NSRange { + let newUpperBound = navigationService.location(movingFrom: range.upperBound, toBoundary: boundary, inDirection: directon) + let lengthDiff = newUpperBound - range.upperBound + return NSRange(location: range.location, length: range.length + lengthDiff) + } +} +#endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift index b45350761..5bb612a07 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift @@ -13,6 +13,7 @@ extension TextView: NSTextInputClient { guard let string = string as? String else { return } + textViewController.selectionService.resetPreviouslySelectedRange() let range = replacementRange.location == NSNotFound ? textViewController.rangeForInsertingText : replacementRange if textViewController.shouldChangeText(in: range, replacementText: string) { textViewController.replaceText(in: range, with: string) @@ -24,7 +25,7 @@ extension TextView: NSTextInputClient { public func unmarkText() {} public func selectedRange() -> NSRange { - textViewController.selectedRange ?? NSRange(location: 0, length: 0) + textViewController.selectedRange?.nonNegativeLength ?? NSRange(location: 0, length: 0) } public func markedRange() -> NSRange { diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 8039dcceb..714e135fd 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -379,12 +379,26 @@ open class TextView: NSView { } } } + /// The color of the selection highlight. It is most common to set this to the same color as the color used for the insertion point. + public var selectionHighlightColor: NSColor = .label.withAlphaComponent(0.2) { + didSet { + if selectionHighlightColor != oldValue { + for (_, view) in selectionViewReuseQueue.visibleViews { + view.backgroundColor = selectionHighlightColor + } + } + } + } + open override var undoManager: UndoManager? { + textViewController.timedUndoManager + } private(set) lazy var textViewController = TextViewController(textView: self, scrollView: scrollView) private let scrollView = NSScrollView() private let scrollContentView = FlippedView() private let caretView = CaretView() + private let selectionViewReuseQueue = ViewReuseQueue() private var isWindowKey = false { didSet { if isWindowKey != oldValue { @@ -519,9 +533,10 @@ open class TextView: NSView { // MARK: - Commands public extension TextView { override func deleteBackward(_ sender: Any?) { - guard var selectedRange = textViewController.markedRange ?? textViewController.selectedRange else { + guard var selectedRange = textViewController.markedRange ?? textViewController.selectedRange?.nonNegativeLength else { return } + textViewController.selectionService.resetPreviouslySelectedRange() if selectedRange.length == 0 { selectedRange.location -= 1 selectedRange.length = 1 @@ -567,14 +582,6 @@ public extension TextView { textViewController.moveRight() } - override func moveUp(_ sender: Any?) { - textViewController.moveUp() - } - - override func moveDown(_ sender: Any?) { - textViewController.moveDown() - } - override func moveForward(_ sender: Any?) { textViewController.moveRight() } @@ -583,6 +590,14 @@ public extension TextView { textViewController.moveLeft() } + override func moveUp(_ sender: Any?) { + textViewController.moveUp() + } + + override func moveDown(_ sender: Any?) { + textViewController.moveDown() + } + override func moveWordLeft(_ sender: Any?) { textViewController.moveWordLeft() } @@ -591,6 +606,14 @@ public extension TextView { textViewController.moveWordRight() } + override func moveWordForward(_ sender: Any?) { + textViewController.moveWordRight() + } + + override func moveWordBackward(_ sender: Any?) { + textViewController.moveWordLeft() + } + override func moveToBeginningOfLine(_ sender: Any?) { textViewController.moveToBeginningOfLine() } @@ -615,10 +638,115 @@ public extension TextView { textViewController.moveToEndOfDocument() } + override func moveLeftAndModifySelection(_ sender: Any?) { + textViewController.moveLeftAndModifySelection() + } + + override func moveRightAndModifySelection(_ sender: Any?) { + textViewController.moveRightAndModifySelection() + } + + override func moveForwardAndModifySelection(_ sender: Any?) { + textViewController.moveRightAndModifySelection() + } + + override func moveBackwardAndModifySelection(_ sender: Any?) { + textViewController.moveLeftAndModifySelection() + } + + override func moveUpAndModifySelection(_ sender: Any?) { + textViewController.moveUpAndModifySelection() + } + + override func moveDownAndModifySelection(_ sender: Any?) { + textViewController.moveDownAndModifySelection() + } + + override func moveWordLeftAndModifySelection(_ sender: Any?) { + textViewController.moveWordLeftAndModifySelection() + } + + override func moveWordRightAndModifySelection(_ sender: Any?) { + textViewController.moveWordRightAndModifySelection() + } + + override func moveWordBackwardAndModifySelection(_ sender: Any?) { + textViewController.moveWordLeftAndModifySelection() + } + + override func moveWordForwardAndModifySelection(_ sender: Any?) { + textViewController.moveWordRightAndModifySelection() + } + + override func moveToBeginningOfLineAndModifySelection(_ sender: Any?) { + textViewController.moveToBeginningOfLineAndModifySelection() + } + + override func moveToEndOfLineAndModifySelection(_ sender: Any?) { + textViewController.moveToEndOfLineAndModifySelection() + } + + override func moveToBeginningOfParagraphAndModifySelection(_ sender: Any?) { + textViewController.moveToBeginningOfParagraphAndModifySelection() + } + + override func moveToEndOfParagraphAndModifySelection(_ sender: Any?) { + textViewController.moveToEndOfParagraphAndModifySelection() + } + + override func moveToBeginningOfDocumentAndModifySelection(_ sender: Any?) { + textViewController.moveToBeginningOfDocumentAndModifySelection() + } + + override func moveToEndOfDocumentAndModifySelection(_ sender: Any?) { + textViewController.moveToEndOfDocumentAndModifySelection() + } + override func mouseDown(with event: NSEvent) { let point = scrollContentView.convert(event.locationInWindow, from: nil) textViewController.moveToLocation(closestTo: point) } + + /// Copy the selected text. + /// + /// - Parameter sender: The object calling this method. + @objc func copy(_ sender: Any?) { + let selectedRange = selectedRange() + if selectedRange.length > 0, let text = textViewController.text(in: selectedRange) { + NSPasteboard.general.declareTypes([.string], owner: nil) + NSPasteboard.general.setString(text, forType: .string) + } + } + + /// Paste text from the pasteboard. + /// + /// - Parameter sender: The object calling this method. + @objc func paste(_ sender: Any?) { + let selectedRange = selectedRange() + if let string = NSPasteboard.general.string(forType: .string) { + print(string) + let preparedText = textViewController.prepareTextForInsertion(string) + textViewController.replaceText(in: selectedRange, with: preparedText) + } + } + + /// Cut text to the pasteboard. + /// + /// - Parameter sender: The object calling this method. + @objc func cut(_ sender: Any?) { + let selectedRange = selectedRange() + if selectedRange.length > 0, let text = textViewController.text(in: selectedRange) { + NSPasteboard.general.setString(text, forType: .string) + textViewController.replaceText(in: selectedRange, with: "") + } + } + + /// Select all text in the text view. + /// + /// - Parameter sender: The object calling this method. + override func selectAll(_ sender: Any?) { + textViewController.selectedRange = NSRange(location: 0, length: textViewController.stringView.string.length) + } } // MARK: - Window @@ -692,6 +820,57 @@ private extension TextView { } } +// MARK: - Selection +private extension TextView { + private func updateSelectedRectangles() { + let selectedRange = selectedRange() + guard selectedRange.length != 0 else { + removeAllLineSelectionViews() + return + } + let caretRectFactory = CaretRectFactory( + stringView: textViewController.stringView, + lineManager: textViewController.lineManager, + lineControllerStorage: textViewController.lineControllerStorage, + gutterWidthService: textViewController.gutterWidthService, + textContainerInset: textContainerInset + ) + let selectionRectFactory = SelectionRectFactory( + lineManager: textViewController.lineManager, + gutterWidthService: textViewController.gutterWidthService, + contentSizeService: textViewController.contentSizeService, + caretRectFactory: caretRectFactory, + textContainerInset: textContainerInset, + lineHeightMultiplier: lineHeightMultiplier + ) + let selectionRects = selectionRectFactory.selectionRects(in: selectedRange) + addLineSelectionViews(for: selectionRects) + } + + private func removeAllLineSelectionViews() { + for (_, view) in selectionViewReuseQueue.visibleViews { + view.removeFromSuperview() + } + let keys = Set(selectionViewReuseQueue.visibleViews.keys) + selectionViewReuseQueue.enqueueViews(withKeys: keys) + } + + private func addLineSelectionViews(for selectionRects: [TextSelectionRect]) { + var appearedViewKeys = Set() + for (idx, selectionRect) in selectionRects.enumerated() { + let key = String(describing: idx) + let view = selectionViewReuseQueue.dequeueView(forKey: key) + view.frame = selectionRect.rect + view.wantsLayer = true + view.backgroundColor = selectionHighlightColor + scrollContentView.addSubview(view) + appearedViewKeys.insert(key) + } + let disappearedViewKeys = Set(selectionViewReuseQueue.visibleViews.keys).subtracting(appearedViewKeys) + selectionViewReuseQueue.enqueueViews(withKeys: disappearedViewKeys) + } +} + // MARK: - TextViewControllerDelegate extension TextView: TextViewControllerDelegate { func textViewControllerDidChangeText(_ textViewController: TextViewController) { @@ -702,7 +881,9 @@ extension TextView: TextViewControllerDelegate { func textViewController(_ textViewController: TextViewController, didChangeSelectedRange selectedRange: NSRange?) { layoutIfNeeded() caretView.delayBlinkIfNeeded() + updateCaretVisibility() updateCaretFrame() + updateSelectedRectangles() scrollToVisibleLocationIfNeeded() } } diff --git a/Sources/Runestone/TextView/Core/NavigationService.swift b/Sources/Runestone/TextView/Core/NavigationService.swift index f9281c4dc..48cd7799d 100644 --- a/Sources/Runestone/TextView/Core/NavigationService.swift +++ b/Sources/Runestone/TextView/Core/NavigationService.swift @@ -1,23 +1,6 @@ import Foundation final class NavigationService { - enum Granularity { - case character - case line - case word - } - - enum Boundary { - case line - case paragraph - case document - } - - enum Direction { - case forward - case backward - } - var stringView: StringView { didSet { if stringView !== oldValue { @@ -57,7 +40,7 @@ final class NavigationService { self.stringTokenizer = StringTokenizer(stringView: stringView, lineManager: lineManager, lineControllerStorage: lineControllerStorage) } - func location(movingFrom sourceLocation: Int, by granularity: Granularity, offset: Int) -> Int { + func location(movingFrom sourceLocation: Int, by offset: Int, granularity: TextGranularity) -> Int { switch granularity { case .character: return location(movingFrom: sourceLocation, byCharacterCount: offset) @@ -68,7 +51,7 @@ final class NavigationService { } } - func location(movingFrom sourceLocation: Int, toBoundary boundary: Boundary, inDirection direction: Direction) -> Int { + func location(movingFrom sourceLocation: Int, toBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> Int { switch boundary { case .line: let mappedDirection = StringTokenizer.Direction(direction) @@ -156,7 +139,7 @@ private extension NavigationService { } private extension StringTokenizer.Direction { - init(_ direction: NavigationService.Direction) { + init(_ direction: TextDirection) { switch direction { case .forward: self = .forward diff --git a/Sources/Runestone/TextView/Core/TextBoundary.swift b/Sources/Runestone/TextView/Core/TextBoundary.swift new file mode 100644 index 000000000..18e0940e7 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextBoundary.swift @@ -0,0 +1,5 @@ +enum TextBoundary { + case line + case paragraph + case document +} diff --git a/Sources/Runestone/TextView/Core/TextDirection.swift b/Sources/Runestone/TextView/Core/TextDirection.swift new file mode 100644 index 000000000..56a7648b9 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextDirection.swift @@ -0,0 +1,4 @@ +enum TextDirection { + case forward + case backward +} diff --git a/Sources/Runestone/TextView/Core/TextGranularity.swift b/Sources/Runestone/TextView/Core/TextGranularity.swift new file mode 100644 index 000000000..e3442a873 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextGranularity.swift @@ -0,0 +1,5 @@ +enum TextGranularity { + case character + case line + case word +} diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift index 4813f43b0..c01616826 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift @@ -3,7 +3,7 @@ import Foundation extension TextViewController { var rangeForInsertingText: NSRange { // If there is no marked range or selected range then we fallback to appending text to the end of our string. - markedRange ?? selectedRange ?? NSRange(location: stringView.string.length, length: 0) + markedRange ?? selectedRange?.nonNegativeLength ?? NSRange(location: stringView.string.length, length: 0) } func text(in range: NSRange) -> String? { diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift index b544a80ca..ec0a2f330 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift @@ -3,84 +3,113 @@ import Foundation extension TextViewController { func moveLeft() { navigationService.resetPreviousLineMovementOperation() - move(by: .character, offset: -1) + resetPreviouslySelectedRange() + move(by: .character, inDirection: .backward) } func moveRight() { navigationService.resetPreviousLineMovementOperation() - move(by: .character, offset: 1) + resetPreviouslySelectedRange() + move(by: .character, inDirection: .forward) } func moveUp() { - move(by: .line, offset: -1) + resetPreviouslySelectedRange() + move(by: .line, inDirection: .backward) } func moveDown() { - move(by: .line, offset: 1) + resetPreviouslySelectedRange() + move(by: .line, inDirection: .forward) } func moveWordLeft() { navigationService.resetPreviousLineMovementOperation() - move(by: .word, offset: -1) + resetPreviouslySelectedRange() + move(by: .word, inDirection: .backward) } func moveWordRight() { navigationService.resetPreviousLineMovementOperation() - move(by: .word, offset: 1) + resetPreviouslySelectedRange() + move(by: .word, inDirection: .forward) } func moveToBeginningOfLine() { navigationService.resetPreviousLineMovementOperation() + resetPreviouslySelectedRange() move(toBoundary: .line, inDirection: .backward) } func moveToEndOfLine() { navigationService.resetPreviousLineMovementOperation() + resetPreviouslySelectedRange() move(toBoundary: .line, inDirection: .forward) } func moveToBeginningOfParagraph() { navigationService.resetPreviousLineMovementOperation() + resetPreviouslySelectedRange() move(toBoundary: .paragraph, inDirection: .backward) } func moveToEndOfParagraph() { navigationService.resetPreviousLineMovementOperation() + resetPreviouslySelectedRange() move(toBoundary: .paragraph, inDirection: .forward) } func moveToBeginningOfDocument() { navigationService.resetPreviousLineMovementOperation() + resetPreviouslySelectedRange() move(toBoundary: .document, inDirection: .backward) } func moveToEndOfDocument() { navigationService.resetPreviousLineMovementOperation() + resetPreviouslySelectedRange() move(toBoundary: .document, inDirection: .forward) } func moveToLocation(closestTo point: CGPoint) { if let location = layoutManager.closestIndex(to: point) { navigationService.resetPreviousLineMovementOperation() + resetPreviouslySelectedRange() selectedRange = NSRange(location: location, length: 0) } } } private extension TextViewController { - private func move(by granularity: NavigationService.Granularity, offset: Int) { - guard let sourceLocation = selectedRange?.location else { + private func move(by granularity: TextGranularity, inDirection direction: TextDirection) { + guard let selectedRange = selectedRange?.nonNegativeLength else { return } - let destinationLocation = navigationService.location(movingFrom: sourceLocation, by: granularity, offset: offset) - selectedRange = NSRange(location: destinationLocation, length: 0) + let shouldMoveToSelectionEnd = selectedRange.length > 0 && granularity == .character + if shouldMoveToSelectionEnd && direction == .forward { + self.selectedRange = NSRange(location: selectedRange.upperBound, length: 0) + } else if shouldMoveToSelectionEnd && direction == .backward { + self.selectedRange = NSRange(location: selectedRange.lowerBound, length: 0) + } else { + let offset = direction == .forward ? 1 : -1 + let sourceLocation = direction == .forward ? selectedRange.upperBound : selectedRange.lowerBound + let destinationLocation = navigationService.location(movingFrom: sourceLocation, by: offset, granularity: granularity) + self.selectedRange = NSRange(location: destinationLocation, length: 0) + } } - private func move(toBoundary boundary: NavigationService.Boundary, inDirection direction: NavigationService.Direction) { - guard let sourceLocation = selectedRange?.location else { + private func move(toBoundary boundary: TextBoundary, inDirection direction: TextDirection) { + guard let selectedRange = selectedRange?.nonNegativeLength else { return } + let sourceLocation = direction == .forward ? selectedRange.upperBound : selectedRange.lowerBound let destinationLocation = navigationService.location(movingFrom: sourceLocation, toBoundary: boundary, inDirection: direction) - selectedRange = NSRange(location: destinationLocation, length: 0) + self.selectedRange = NSRange(location: destinationLocation, length: 0) + } + + private func resetPreviouslySelectedRange() { + #if os(macOS) + selectionService.resetPreviouslySelectedRange() + #endif } } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift new file mode 100644 index 000000000..918dd4f0f --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift @@ -0,0 +1,69 @@ +#if os(macOS) +import Foundation + +extension TextViewController { + func moveLeftAndModifySelection() { + move(by: .character, inDirection: .backward) + } + + func moveRightAndModifySelection() { + move(by: .character, inDirection: .forward) + } + + func moveUpAndModifySelection() { + move(by: .line, inDirection: .backward) + } + + func moveDownAndModifySelection() { + move(by: .line, inDirection: .forward) + } + + func moveWordLeftAndModifySelection() { + move(by: .word, inDirection: .backward) + } + + func moveWordRightAndModifySelection() { + move(by: .word, inDirection: .forward) + } + + func moveToBeginningOfLineAndModifySelection() { + move(toBoundary: .line, inDirection: .backward) + } + + func moveToEndOfLineAndModifySelection() { + move(toBoundary: .line, inDirection: .forward) + } + + func moveToBeginningOfParagraphAndModifySelection() { + move(toBoundary: .paragraph, inDirection: .backward) + } + + func moveToEndOfParagraphAndModifySelection() { + move(toBoundary: .paragraph, inDirection: .forward) + } + + func moveToBeginningOfDocumentAndModifySelection() { + move(toBoundary: .document, inDirection: .backward) + } + + func moveToEndOfDocumentAndModifySelection() { + move(toBoundary: .document, inDirection: .forward) + } +} + +private extension TextViewController { + private func move(by granularity: TextGranularity, inDirection directon: TextDirection) { + if let currentlySelectedRange = selectedRange { + navigationService.resetPreviousLineMovementOperation() + selectedRange = selectionService.range(movingFrom: currentlySelectedRange, by: granularity, inDirection: directon) + } + } + + private func move(toBoundary boundary: TextBoundary, inDirection directon: TextDirection) { + if let currentlySelectedRange = selectedRange { + navigationService.resetPreviousLineMovementOperation() + selectedRange = selectionService.range(movingFrom: currentlySelectedRange, toBoundary: boundary, inDirection: directon) + } + } +} +#endif diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 5b58332ed..8200e3b2e 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -168,6 +168,9 @@ final class TextViewController { let gutterWidthService: GutterWidthService let contentSizeService: ContentSizeService let navigationService: NavigationService + #if os(macOS) + let selectionService: SelectionService + #endif let layoutManager: LayoutManager let indentController: IndentController let pageGuideController = PageGuideController() @@ -555,6 +558,9 @@ final class TextViewController { lineManager: lineManager, lineControllerStorage: lineControllerStorage ) + #if os(macOS) + selectionService = SelectionService(navigationService: navigationService) + #endif layoutManager = LayoutManager( lineManager: lineManager, languageMode: languageMode, diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index 68836add1..987c7feb0 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -302,16 +302,16 @@ public extension TextView { let navigationService = textViewController.navigationService switch direction { case .right: - let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: .character, offset: offset) + let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: offset, granularity: .character) return IndexedPosition(index: newLocation) case .left: - let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: .character, offset: offset * -1) + let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: offset * -1, granularity: .character) return IndexedPosition(index: newLocation) case .up: - let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: .line, offset: offset * -1) + let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: offset * -1, granularity: .line) return IndexedPosition(index: newLocation) case .down: - let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: .line, offset: offset) + let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: offset, granularity: .line) return IndexedPosition(index: newLocation) @unknown default: return nil From 27c9b5ae9d176bd862965b4fd0dc9184a698f510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 07:36:43 +0100 Subject: [PATCH 116/232] Removes debug log --- Sources/Runestone/TextView/Core/Mac/SelectionService.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/SelectionService.swift b/Sources/Runestone/TextView/Core/Mac/SelectionService.swift index 2abbbb49a..f21a66244 100644 --- a/Sources/Runestone/TextView/Core/Mac/SelectionService.swift +++ b/Sources/Runestone/TextView/Core/Mac/SelectionService.swift @@ -17,11 +17,8 @@ final class SelectionService { } func range(movingFrom currentlySelectedRange: NSRange, toBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> NSRange { - print(previouslySelectedRange) - print(currentlySelectedRange) let selectedRange = previouslySelectedRange ?? currentlySelectedRange let newSelectedRange = move(selectedRange, toBoundary: boundary, inDirection: direction) - print(newSelectedRange) previouslySelectedRange = newSelectedRange return newSelectedRange } From 04b8a08a14ad0aed02dfddfb26bfe4675ddfe9da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 07:36:57 +0100 Subject: [PATCH 117/232] Fixes line manager not updated --- .../Runestone/TextView/Core/NavigationService.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Sources/Runestone/TextView/Core/NavigationService.swift b/Sources/Runestone/TextView/Core/NavigationService.swift index 48cd7799d..8ea255b1b 100644 --- a/Sources/Runestone/TextView/Core/NavigationService.swift +++ b/Sources/Runestone/TextView/Core/NavigationService.swift @@ -9,11 +9,11 @@ final class NavigationService { } } var lineManager: LineManager { - get { - return lineNavigationService.lineManager - } - set { - lineNavigationService.lineManager = newValue + didSet { + if lineManager !== oldValue { + lineNavigationService.lineManager = lineManager + stringTokenizer.lineManager = lineManager + } } } var lineControllerStorage: LineControllerStorage { @@ -36,6 +36,7 @@ final class NavigationService { init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { self.stringView = stringView + self.lineManager = lineManager self.lineNavigationService = LineNavigationService(lineManager: lineManager, lineControllerStorage: lineControllerStorage) self.stringTokenizer = StringTokenizer(stringView: stringView, lineManager: lineManager, lineControllerStorage: lineControllerStorage) } From dcd585bc425bd095966b5708679251eb3d8e7132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 07:41:46 +0100 Subject: [PATCH 118/232] Fixes line selection --- .../TextViewController+Selection.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift index 918dd4f0f..29385f6ff 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift @@ -3,10 +3,12 @@ import Foundation extension TextViewController { func moveLeftAndModifySelection() { + navigationService.resetPreviousLineMovementOperation() move(by: .character, inDirection: .backward) } func moveRightAndModifySelection() { + navigationService.resetPreviousLineMovementOperation() move(by: .character, inDirection: .forward) } @@ -19,34 +21,42 @@ extension TextViewController { } func moveWordLeftAndModifySelection() { + navigationService.resetPreviousLineMovementOperation() move(by: .word, inDirection: .backward) } func moveWordRightAndModifySelection() { + navigationService.resetPreviousLineMovementOperation() move(by: .word, inDirection: .forward) } func moveToBeginningOfLineAndModifySelection() { + navigationService.resetPreviousLineMovementOperation() move(toBoundary: .line, inDirection: .backward) } func moveToEndOfLineAndModifySelection() { + navigationService.resetPreviousLineMovementOperation() move(toBoundary: .line, inDirection: .forward) } func moveToBeginningOfParagraphAndModifySelection() { + navigationService.resetPreviousLineMovementOperation() move(toBoundary: .paragraph, inDirection: .backward) } func moveToEndOfParagraphAndModifySelection() { + navigationService.resetPreviousLineMovementOperation() move(toBoundary: .paragraph, inDirection: .forward) } func moveToBeginningOfDocumentAndModifySelection() { + navigationService.resetPreviousLineMovementOperation() move(toBoundary: .document, inDirection: .backward) } func moveToEndOfDocumentAndModifySelection() { + navigationService.resetPreviousLineMovementOperation() move(toBoundary: .document, inDirection: .forward) } } @@ -54,14 +64,12 @@ extension TextViewController { private extension TextViewController { private func move(by granularity: TextGranularity, inDirection directon: TextDirection) { if let currentlySelectedRange = selectedRange { - navigationService.resetPreviousLineMovementOperation() selectedRange = selectionService.range(movingFrom: currentlySelectedRange, by: granularity, inDirection: directon) } } private func move(toBoundary boundary: TextBoundary, inDirection directon: TextDirection) { if let currentlySelectedRange = selectedRange { - navigationService.resetPreviousLineMovementOperation() selectedRange = selectionService.range(movingFrom: currentlySelectedRange, toBoundary: boundary, inDirection: directon) } } From 04e2e17f915e6bd4bae810ed0b3af8c6f87ac56b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 07:53:58 +0100 Subject: [PATCH 119/232] Adds implicit_return SwiftLint rule --- .swiftlint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.swiftlint.yml b/.swiftlint.yml index b0d1e1294..098f58135 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -30,6 +30,7 @@ opt_in_rules: - first_where - flatmap_over_map_reduce - identical_operands + - implicit_return - implicitly_unwrapped_optional - joined_default_parameter - last_where From cba1e6736fafdde4f5c3b903c17dfb1b211f6929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 07:54:17 +0100 Subject: [PATCH 120/232] Fixes SwiftLint warnings --- .../IndentationScopes.swift | 2 +- .../TreeSitterLanguage.swift | 2 +- Example/MacExample/AppDelegate.swift | 2 +- .../PlainTextTheme.swift | 2 +- Example/iOSExample/Library/CodeSample.swift | 2 +- .../Library/ProcessInfo+Helpers.swift | 4 +- .../Library/UserDefaults+Helpers.swift | 16 +-- .../iOSExample/Main/MainViewController.swift | 4 +- Sources/Runestone/Library/ByteCount.swift | 24 ++-- Sources/Runestone/Library/ByteRange.swift | 10 +- Sources/Runestone/Library/Caret.swift | 3 +- .../Library/Mac/NSScrollView+Helpers.swift | 2 +- .../Runestone/Library/NSString+Helpers.swift | 4 +- .../Runestone/Library/String+Helpers.swift | 2 +- .../Library/iOS/UIScrollView+Helpers.swift | 2 +- .../LineManager/DocumentLineNodeData.swift | 12 +- .../Runestone/LineManager/LineChangeSet.swift | 2 +- .../Runestone/LineManager/LineManager.swift | 32 +++--- .../Runestone/LineManager/LinePosition.swift | 4 +- .../MultiPlatform/MultiPlatformFont.swift | 2 +- .../ClosedRangeValueDescriptor.swift | 4 +- .../Runestone/RedBlackTree/RedBlackTree.swift | 12 +- .../RedBlackTreeChildrenUpdater.swift | 2 +- .../RedBlackTree/RedBlackTreeNode.swift | 8 +- .../Runestone/TextView/Appearance/Theme.swift | 8 +- .../Mac/TextView_Mac+NSTextInputClient.swift | 3 +- .../TextView/Core/Mac/TextView_Mac.swift | 86 +++++++------- .../TextView/Core/NavigationService.swift | 2 +- .../TextView/Core/StringTokenizer.swift | 10 +- .../Runestone/TextView/Core/StringView.swift | 2 +- .../TextViewController+ContentSize.swift | 4 +- .../TextViewController+Layout.swift | 2 +- .../TextViewController+Selection.swift | 2 +- .../TextViewController.swift | 47 ++++---- .../TextView/Core/TextViewDelegate.swift | 12 +- .../TextView/Core/TimedUndoManager.swift | 2 +- .../Core/iOS/EditMenuController.swift | 6 +- .../TextView/Core/iOS/IndexedRange.swift | 6 +- .../Core/iOS/TextInputStringTokenizer.swift | 10 +- .../Core/iOS/TextView_iOS+UITextInput.swift | 7 +- .../TextView/Core/iOS/TextView_iOS.swift | 108 +++++++++--------- .../Gutter/GutterBackgroundView.swift | 2 +- .../TextView/Highlight/HighlightedRange.swift | 4 +- .../Highlight/HighlightedRangeFragment.swift | 2 +- .../TextView/Indent/IndentController.swift | 8 +- .../LineController/LineController.swift | 14 +-- .../LineControllerFactory.swift | 8 +- .../LineControllerStorage.swift | 4 +- .../LineController/LineFragment.swift | 4 +- .../LineFragmentCharacterLocationQuery.swift | 4 +- .../LineFragmentController.swift | 10 +- .../LineFragmentFrameQuery.swift | 4 +- .../LineController/LineFragmentNode.swift | 2 +- .../LineController/LineFragmentRenderer.swift | 4 +- .../LineController/LineTypesetter.swift | 4 +- .../TextView/PageGuide/PageGuideView.swift | 2 +- .../ParsedReplacementString.swift | 2 +- .../SearchAndReplace/SearchController.swift | 8 +- .../SearchAndReplace/SearchQuery.swift | 2 +- .../SearchAndReplace/StringModifier.swift | 2 +- .../iOS/UITextSearchingHelper.swift | 6 +- .../PlainTextInternalLanguageMode.swift | 16 +-- .../PlainTextSyntaxHighlighter.swift | 4 +- .../TreeSitterInternalLanguageMode.swift | 8 +- .../TreeSitter/TreeSitterLanguageLayer.swift | 4 +- .../TreeSitterSyntaxHighlightToken.swift | 6 +- .../TreeSitterSyntaxHighlighter.swift | 2 +- .../PlainText/PlainTextLanguageMode.swift | 2 +- .../TreeSitterIndentationScopes.swift | 2 +- .../TreeSitter/TreeSitterLanguageMode.swift | 2 +- .../TextSelection/SelectionRectFactory.swift | 4 +- .../TextSelection/iOS/TextSelectionRect.swift | 10 +- .../TreeSitter/TreeSitterCapture.swift | 2 +- .../TreeSitter/TreeSitterInputEdit.swift | 2 +- .../Runestone/TreeSitter/TreeSitterNode.swift | 24 ++-- .../TreeSitter/TreeSitterParser.swift | 4 +- .../TreeSitter/TreeSitterPredicate.swift | 2 +- .../TreeSitter/TreeSitterQuery.swift | 2 +- .../TreeSitter/TreeSitterQueryCursor.swift | 2 +- .../TreeSitter/TreeSitterQueryMatch.swift | 4 +- .../TreeSitter/TreeSitterTextPoint.swift | 6 +- .../TreeSitter/TreeSitterTextPredicate.swift | 6 +- .../TreeSitter/TreeSitterTextRange.swift | 10 +- .../Runestone/TreeSitter/TreeSitterTree.swift | 4 +- .../Mock/MockTreeSitterParserDelegate.swift | 2 +- Tests/RunestoneTests/XCTestManifests.swift | 2 +- .../iOS/TextInputStringTokenizerTests.swift | 2 +- UITests/Host/Sources/AppDelegate.swift | 2 +- .../Host/Sources/ProcessInfo+Helpers.swift | 2 +- .../IndentationScopes.swift | 2 +- .../TreeSitterLanguage.swift | 2 +- 91 files changed, 362 insertions(+), 352 deletions(-) diff --git a/Example/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift b/Example/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift index 988a5730b..c2881dde2 100644 --- a/Example/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift +++ b/Example/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift @@ -2,7 +2,7 @@ import Runestone public extension TreeSitterIndentationScopes { static var javaScript: TreeSitterIndentationScopes { - return TreeSitterIndentationScopes( + TreeSitterIndentationScopes( indent: [ "array", "object", diff --git a/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift b/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift index 8142ac143..f757cf95a 100644 --- a/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift +++ b/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift @@ -17,6 +17,6 @@ public extension TreeSitterLanguage { private extension TreeSitterLanguage { static func queryFileURL(forQueryNamed queryName: String) -> URL { - return Bundle.module.url(forResource: "queries/" + queryName, withExtension: "scm")! + Bundle.module.url(forResource: "queries/" + queryName, withExtension: "scm")! } } diff --git a/Example/MacExample/AppDelegate.swift b/Example/MacExample/AppDelegate.swift index 28da7277b..ba8866110 100644 --- a/Example/MacExample/AppDelegate.swift +++ b/Example/MacExample/AppDelegate.swift @@ -7,6 +7,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ aNotification: Notification) {} func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true + true } } diff --git a/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift b/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift index 3d70b03f6..4676f48e9 100644 --- a/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift +++ b/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift @@ -37,7 +37,7 @@ public final class PlainTextTheme: EditorTheme { public init() {} public func textColor(for rawHighlightName: String) -> MultiPlatformColor? { - return nil + nil } public func fontTraits(for rawHighlightName: String) -> FontTraits { diff --git a/Example/iOSExample/Library/CodeSample.swift b/Example/iOSExample/Library/CodeSample.swift index d516779c6..2150d6c7d 100644 --- a/Example/iOSExample/Library/CodeSample.swift +++ b/Example/iOSExample/Library/CodeSample.swift @@ -2,7 +2,7 @@ import Foundation enum CodeSample { static var `default`: String { - return """ + """ /** * This is a Runestone text view with syntax highlighting * for the JavaScript programming language. diff --git a/Example/iOSExample/Library/ProcessInfo+Helpers.swift b/Example/iOSExample/Library/ProcessInfo+Helpers.swift index 5e19736cc..efc364d3f 100644 --- a/Example/iOSExample/Library/ProcessInfo+Helpers.swift +++ b/Example/iOSExample/Library/ProcessInfo+Helpers.swift @@ -2,10 +2,10 @@ import Foundation extension ProcessInfo { var disableTextPersistance: Bool { - return environment["disableTextPersistance"] != nil + environment["disableTextPersistance"] != nil } var useCRLFLineEndings: Bool { - return environment["crlfLineEndings"] != nil + environment["crlfLineEndings"] != nil } } diff --git a/Example/iOSExample/Library/UserDefaults+Helpers.swift b/Example/iOSExample/Library/UserDefaults+Helpers.swift index 2bdae7aee..67d65d23d 100644 --- a/Example/iOSExample/Library/UserDefaults+Helpers.swift +++ b/Example/iOSExample/Library/UserDefaults+Helpers.swift @@ -15,7 +15,7 @@ extension UserDefaults { var text: String? { get { - return string(forKey: Key.text) + string(forKey: Key.text) } set { set(newValue, forKey: Key.text) @@ -23,7 +23,7 @@ extension UserDefaults { } var showLineNumbers: Bool { get { - return bool(forKey: Key.showLineNumbers) + bool(forKey: Key.showLineNumbers) } set { set(newValue, forKey: Key.showLineNumbers) @@ -31,7 +31,7 @@ extension UserDefaults { } var showInvisibleCharacters: Bool { get { - return bool(forKey: Key.showInvisibleCharacters) + bool(forKey: Key.showInvisibleCharacters) } set { set(newValue, forKey: Key.showInvisibleCharacters) @@ -39,7 +39,7 @@ extension UserDefaults { } var wrapLines: Bool { get { - return bool(forKey: Key.wrapLines) + bool(forKey: Key.wrapLines) } set { set(newValue, forKey: Key.wrapLines) @@ -47,7 +47,7 @@ extension UserDefaults { } var highlightSelectedLine: Bool { get { - return bool(forKey: Key.highlightSelectedLine) + bool(forKey: Key.highlightSelectedLine) } set { set(newValue, forKey: Key.highlightSelectedLine) @@ -55,7 +55,7 @@ extension UserDefaults { } var showPageGuide: Bool { get { - return bool(forKey: Key.showPageGuide) + bool(forKey: Key.showPageGuide) } set { set(newValue, forKey: Key.showPageGuide) @@ -76,7 +76,7 @@ extension UserDefaults { var isEditable: Bool { get { - return bool(forKey: Key.isEditable) + bool(forKey: Key.isEditable) } set { set(newValue, forKey: Key.isEditable) @@ -85,7 +85,7 @@ extension UserDefaults { var isSelectable: Bool { get { - return bool(forKey: Key.isSelectable) + bool(forKey: Key.isSelectable) } set { set(newValue, forKey: Key.isSelectable) diff --git a/Example/iOSExample/Main/MainViewController.swift b/Example/iOSExample/Main/MainViewController.swift index 4cc360761..7437257e3 100644 --- a/Example/iOSExample/Main/MainViewController.swift +++ b/Example/iOSExample/Main/MainViewController.swift @@ -155,7 +155,7 @@ private extension MainViewController { } private func makeThemeMenuElements() -> [UIMenuElement] { - return [ + [ UIAction(title: "Theme") { [weak self] _ in self?.presentThemePicker() } @@ -213,7 +213,7 @@ extension MainViewController: TextViewDelegate { } func textView(_ textView: TextView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - return true + true } } diff --git a/Sources/Runestone/Library/ByteCount.swift b/Sources/Runestone/Library/ByteCount.swift index 1666efd9f..cb512b594 100644 --- a/Sources/Runestone/Library/ByteCount.swift +++ b/Sources/Runestone/Library/ByteCount.swift @@ -3,7 +3,7 @@ import Foundation struct ByteCount: Hashable { private(set) var value: Int var utf16Length: Int { - return value / 2 + value / 2 } init(_ value: Int) { @@ -21,19 +21,19 @@ struct ByteCount: Hashable { extension ByteCount: Comparable { static func < (lhs: ByteCount, rhs: ByteCount) -> Bool { - return lhs.value < rhs.value + lhs.value < rhs.value } static func <= (lhs: ByteCount, rhs: ByteCount) -> Bool { - return lhs.value <= rhs.value + lhs.value <= rhs.value } static func >= (lhs: ByteCount, rhs: ByteCount) -> Bool { - return lhs.value >= rhs.value + lhs.value >= rhs.value } static func > (lhs: ByteCount, rhs: ByteCount) -> Bool { - return lhs.value > rhs.value + lhs.value > rhs.value } } @@ -42,11 +42,11 @@ extension ByteCount: Numeric { typealias IntegerLiteralType = Int static var zero: ByteCount { - return ByteCount(0) + ByteCount(0) } var magnitude: Int { - return value + value } init?(exactly source: T) where T: BinaryInteger { @@ -58,7 +58,7 @@ extension ByteCount: Numeric { } static func - (lhs: ByteCount, rhs: ByteCount) -> ByteCount { - return ByteCount(lhs.value - rhs.value) + ByteCount(lhs.value - rhs.value) } static func -= (lhs: inout ByteCount, rhs: ByteCount) { @@ -66,7 +66,7 @@ extension ByteCount: Numeric { } static func + (lhs: ByteCount, rhs: ByteCount) -> ByteCount { - return ByteCount(lhs.value + rhs.value) + ByteCount(lhs.value + rhs.value) } static func += (lhs: inout ByteCount, rhs: ByteCount) { @@ -74,7 +74,7 @@ extension ByteCount: Numeric { } static func * (lhs: ByteCount, rhs: ByteCount) -> ByteCount { - return ByteCount(lhs.value * rhs.value) + ByteCount(lhs.value * rhs.value) } static func *= (lhs: inout ByteCount, rhs: ByteCount) { @@ -84,12 +84,12 @@ extension ByteCount: Numeric { extension ByteCount: CustomStringConvertible { var description: String { - return "\(value)" + "\(value)" } } extension ByteCount: CustomDebugStringConvertible { var debugDescription: String { - return "\(value)" + "\(value)" } } diff --git a/Sources/Runestone/Library/ByteRange.swift b/Sources/Runestone/Library/ByteRange.swift index c48105671..88a953311 100644 --- a/Sources/Runestone/Library/ByteRange.swift +++ b/Sources/Runestone/Library/ByteRange.swift @@ -4,13 +4,13 @@ struct ByteRange: Hashable { let location: ByteCount let length: ByteCount var lowerBound: ByteCount { - return location + location } var upperBound: ByteCount { - return location + length + location + length } var isEmpty: Bool { - return length == 0 + length == 0 } init(location: ByteCount, length: ByteCount) { @@ -37,12 +37,12 @@ struct ByteRange: Hashable { extension ByteRange: CustomStringConvertible { var description: String { - return "{\(location), \(length)}" + "{\(location), \(length)}" } } extension ByteRange: CustomDebugStringConvertible { var debugDescription: String { - return "{\(location), \(length)}" + "{\(location), \(length)}" } } diff --git a/Sources/Runestone/Library/Caret.swift b/Sources/Runestone/Library/Caret.swift index 600e44f22..da84e3c8c 100644 --- a/Sources/Runestone/Library/Caret.swift +++ b/Sources/Runestone/Library/Caret.swift @@ -7,8 +7,7 @@ enum Caret { static let width: CGFloat = 1 #endif - static func defaultHeight(for font: MultiPlatformFont?) -> CGFloat { - return font?.lineHeight ?? 15 + font?.lineHeight ?? 15 } } diff --git a/Sources/Runestone/Library/Mac/NSScrollView+Helpers.swift b/Sources/Runestone/Library/Mac/NSScrollView+Helpers.swift index da4f7ed73..ca0be63c4 100644 --- a/Sources/Runestone/Library/Mac/NSScrollView+Helpers.swift +++ b/Sources/Runestone/Library/Mac/NSScrollView+Helpers.swift @@ -13,7 +13,7 @@ extension MultiPlatformScrollView { var contentOffset: CGPoint { get { - return documentVisibleRect.origin + documentVisibleRect.origin } set { documentView?.scroll(newValue) diff --git a/Sources/Runestone/Library/NSString+Helpers.swift b/Sources/Runestone/Library/NSString+Helpers.swift index ad123eaf4..142eaaa0d 100644 --- a/Sources/Runestone/Library/NSString+Helpers.swift +++ b/Sources/Runestone/Library/NSString+Helpers.swift @@ -2,7 +2,7 @@ import Foundation extension NSString { var byteCount: ByteCount { - return ByteCount(length * 2) + ByteCount(length * 2) } func getAllBytes(withEncoding encoding: String.Encoding, usedLength: inout Int) -> UnsafePointer? { @@ -48,6 +48,6 @@ extension NSString { private extension NSString { private func isCRLFLineEnding(in range: NSRange) -> Bool { - return substring(with: range) == Symbol.carriageReturnLineFeed + substring(with: range) == Symbol.carriageReturnLineFeed } } diff --git a/Sources/Runestone/Library/String+Helpers.swift b/Sources/Runestone/Library/String+Helpers.swift index e9135a0ca..deac2398b 100644 --- a/Sources/Runestone/Library/String+Helpers.swift +++ b/Sources/Runestone/Library/String+Helpers.swift @@ -9,6 +9,6 @@ extension String { } var byteCount: ByteCount { - return ByteCount(utf16.count * 2) + ByteCount(utf16.count * 2) } } diff --git a/Sources/Runestone/Library/iOS/UIScrollView+Helpers.swift b/Sources/Runestone/Library/iOS/UIScrollView+Helpers.swift index 10c1d5b93..bab3aaaa2 100644 --- a/Sources/Runestone/Library/iOS/UIScrollView+Helpers.swift +++ b/Sources/Runestone/Library/iOS/UIScrollView+Helpers.swift @@ -3,7 +3,7 @@ import UIKit extension UIScrollView { var minimumContentOffset: CGPoint { - return CGPoint(x: adjustedContentInset.left * -1, y: adjustedContentInset.top * -1) + CGPoint(x: adjustedContentInset.left * -1, y: adjustedContentInset.top * -1) } var maximumContentOffset: CGPoint { diff --git a/Sources/Runestone/LineManager/DocumentLineNodeData.swift b/Sources/Runestone/LineManager/DocumentLineNodeData.swift index 22fc31ba1..e68044078 100644 --- a/Sources/Runestone/LineManager/DocumentLineNodeData.swift +++ b/Sources/Runestone/LineManager/DocumentLineNodeData.swift @@ -9,20 +9,20 @@ final class DocumentLineNodeData { } var totalLength = 0 var length: Int { - return totalLength - delimiterLength + totalLength - delimiterLength } var lineHeight: CGFloat var totalLineHeight: CGFloat = 0 var nodeTotalByteCount = ByteCount(0) var startByte: ByteCount { - return node!.tree.startByte(of: node!) + node!.tree.startByte(of: node!) } var byteCount = ByteCount(0) var byteRange: ByteRange { - return ByteRange(location: startByte, length: byteCount - ByteCount(delimiterLength)) + ByteRange(location: startByte, length: byteCount - ByteCount(delimiterLength)) } var totalByteRange: ByteRange { - return ByteRange(location: startByte, length: byteCount) + ByteRange(location: startByte, length: byteCount) } weak var node: DocumentLineNode? @@ -34,12 +34,12 @@ final class DocumentLineNodeData { private extension DocumentLineTree { func startByte(of node: Node) -> ByteCount { - return offset(of: node, valueKeyPath: \.data.byteCount, totalValueKeyPath: \.data.nodeTotalByteCount, minimumValue: ByteCount(0)) + offset(of: node, valueKeyPath: \.data.byteCount, totalValueKeyPath: \.data.nodeTotalByteCount, minimumValue: ByteCount(0)) } } extension DocumentLineNodeData: CustomDebugStringConvertible { var debugDescription: String { - return "[DocumentLineNodeData length=\(length) delimiterLength=\(delimiterLength) totalLength=\(totalLength)]" + "[DocumentLineNodeData length=\(length) delimiterLength=\(delimiterLength) totalLength=\(totalLength)]" } } diff --git a/Sources/Runestone/LineManager/LineChangeSet.swift b/Sources/Runestone/LineManager/LineChangeSet.swift index b4800e2d8..c5c4e4a74 100644 --- a/Sources/Runestone/LineManager/LineChangeSet.swift +++ b/Sources/Runestone/LineManager/LineChangeSet.swift @@ -32,6 +32,6 @@ final class LineChangeSet { extension LineChangeSet: CustomDebugStringConvertible { var debugDescription: String { - return "[LineChangeSet insertedLines=\(insertedLines) removedLines=\(removedLines) editedLines=\(editedLines)]" + "[LineChangeSet insertedLines=\(insertedLines) removedLines=\(removedLines) editedLines=\(editedLines)]" } } diff --git a/Sources/Runestone/LineManager/LineManager.swift b/Sources/Runestone/LineManager/LineManager.swift index 677fadb2f..b4ce3d731 100644 --- a/Sources/Runestone/LineManager/LineManager.swift +++ b/Sources/Runestone/LineManager/LineManager.swift @@ -4,13 +4,13 @@ import Foundation struct DocumentLineNodeID: RedBlackTreeNodeID, Hashable { let id = UUID() var rawValue: String { - return id.uuidString + id.uuidString } } extension DocumentLineNodeID: CustomDebugStringConvertible { var debugDescription: String { - return rawValue + rawValue } } @@ -20,7 +20,7 @@ typealias DocumentLineNode = RedBlackTreeNode DocumentLineNode? { - return documentLineTree.node(containingLocation: yOffset, - minimumValue: 0, - valueKeyPath: \.data.lineHeight, - totalValueKeyPath: \.data.totalLineHeight) + documentLineTree.node(containingLocation: yOffset, + minimumValue: 0, + valueKeyPath: \.data.lineHeight, + totalValueKeyPath: \.data.totalLineHeight) } func line(containingByteAt byteIndex: ByteCount) -> DocumentLineNode? { - return documentLineTree.node(containingLocation: byteIndex, - minimumValue: ByteCount(0), - valueKeyPath: \.data.byteCount, - totalValueKeyPath: \.data.nodeTotalByteCount) + documentLineTree.node(containingLocation: byteIndex, + minimumValue: ByteCount(0), + valueKeyPath: \.data.byteCount, + totalValueKeyPath: \.data.nodeTotalByteCount) } func line(atRow row: Int) -> DocumentLineNode { - return documentLineTree.node(atIndex: row) + documentLineTree.node(atIndex: row) } @discardableResult @@ -277,7 +277,7 @@ final class LineManager { } func createLineIterator() -> RedBlackTreeIterator { - return RedBlackTreeIterator(tree: documentLineTree) + RedBlackTreeIterator(tree: documentLineTree) } } @@ -368,7 +368,7 @@ extension DocumentLineTree { extension DocumentLineNode { var yPosition: CGFloat { - return tree.yPosition(of: self) + tree.yPosition(of: self) } var range: ClosedRange { diff --git a/Sources/Runestone/LineManager/LinePosition.swift b/Sources/Runestone/LineManager/LinePosition.swift index 8948ce726..6e9ddbe17 100644 --- a/Sources/Runestone/LineManager/LinePosition.swift +++ b/Sources/Runestone/LineManager/LinePosition.swift @@ -16,7 +16,7 @@ final class LinePosition: Hashable, Equatable { } static func == (lhs: LinePosition, rhs: LinePosition) -> Bool { - return lhs.row == rhs.row && lhs.column == rhs.column + lhs.row == rhs.row && lhs.column == rhs.column } func hash(into hasher: inout Hasher) { @@ -27,6 +27,6 @@ final class LinePosition: Hashable, Equatable { extension LinePosition: CustomDebugStringConvertible { var debugDescription: String { - return "[LinePosition row=\(row) column=\(column)]" + "[LinePosition row=\(row) column=\(column)]" } } diff --git a/Sources/Runestone/MultiPlatform/MultiPlatformFont.swift b/Sources/Runestone/MultiPlatform/MultiPlatformFont.swift index 15049012a..d68bc64e5 100644 --- a/Sources/Runestone/MultiPlatform/MultiPlatformFont.swift +++ b/Sources/Runestone/MultiPlatform/MultiPlatformFont.swift @@ -10,7 +10,7 @@ public typealias MultiPlatformFontDescriptor = UIFontDescriptor extension MultiPlatformFont { var totalLineHeight: CGFloat { - return ascender + abs(descender) + leading + ascender + abs(descender) + leading } } diff --git a/Sources/Runestone/RedBlackTree/ClosedRangeValueDescriptor.swift b/Sources/Runestone/RedBlackTree/ClosedRangeValueDescriptor.swift index 4d3a1ad43..7c2a77aa1 100644 --- a/Sources/Runestone/RedBlackTree/ClosedRangeValueDescriptor.swift +++ b/Sources/Runestone/RedBlackTree/ClosedRangeValueDescriptor.swift @@ -9,11 +9,11 @@ final class ClosedRangeValueSearchQuery) -> Bool { - return location(of: node) > range.upperBound + location(of: node) > range.upperBound } func shouldTraverseRightChildren(of node: RedBlackTreeNode) -> Bool { - return location(of: node) + node.value < range.upperBound + location(of: node) + node.value < range.upperBound } func shouldInclude(_ node: RedBlackTreeNode) -> Bool { diff --git a/Sources/Runestone/RedBlackTree/RedBlackTree.swift b/Sources/Runestone/RedBlackTree/RedBlackTree.swift index e63d13e9b..0d174e495 100644 --- a/Sources/Runestone/RedBlackTree/RedBlackTree.swift +++ b/Sources/Runestone/RedBlackTree/RedBlackTree.swift @@ -7,10 +7,10 @@ final class RedBlackTree? @@ -119,7 +119,7 @@ final class RedBlackTree NodeValue { - return offset(of: node, valueKeyPath: \.value, totalValueKeyPath: \.nodeTotalValue, minimumValue: minimumValue) + offset(of: node, valueKeyPath: \.value, totalValueKeyPath: \.nodeTotalValue, minimumValue: minimumValue) } func offset(of node: Node, valueKeyPath: KeyPath, totalValueKeyPath: KeyPath, minimumValue: T) -> T { @@ -482,7 +482,7 @@ private extension RedBlackTree { } private func getColor(of node: Node?) -> RedBlackTreeNodeColor { - return node?.color ?? .black + node?.color ?? .black } private func buildTree(from nodes: [Node], start: Int, end: Int, subtreeHeight: Int) -> Node? { @@ -514,7 +514,7 @@ private extension RedBlackTree { extension RedBlackTree: CustomDebugStringConvertible { var debugDescription: String { - return append(root, to: "", indent: 0) + append(root, to: "", indent: 0) } private func append(_ node: Node, to string: String, indent: Int) -> String { @@ -552,6 +552,6 @@ extension RedBlackTree where NodeData == Void { @discardableResult func insertNode(value: NodeValue, after existingNode: Node) -> Node { - return insertNode(value: value, data: (), after: existingNode) + insertNode(value: value, data: (), after: existingNode) } } diff --git a/Sources/Runestone/RedBlackTree/RedBlackTreeChildrenUpdater.swift b/Sources/Runestone/RedBlackTree/RedBlackTreeChildrenUpdater.swift index 6bb1eb123..954e6e20b 100644 --- a/Sources/Runestone/RedBlackTree/RedBlackTreeChildrenUpdater.swift +++ b/Sources/Runestone/RedBlackTree/RedBlackTreeChildrenUpdater.swift @@ -4,6 +4,6 @@ class RedBlackTreeChildrenUpdater func updateAfterChangingChildren(of node: Node) -> Bool { - return false + false } } diff --git a/Sources/Runestone/RedBlackTree/RedBlackTreeNode.swift b/Sources/Runestone/RedBlackTree/RedBlackTreeNode.swift index 61ca2db59..6661611fd 100644 --- a/Sources/Runestone/RedBlackTree/RedBlackTreeNode.swift +++ b/Sources/Runestone/RedBlackTree/RedBlackTreeNode.swift @@ -13,11 +13,11 @@ final class RedBlackTreeNode, rhs: RedBlackTreeNode) -> Bool { - return lhs.id == rhs.id + lhs.id == rhs.id } func hash(into hasher: inout Hasher) { @@ -104,6 +104,6 @@ extension RedBlackTreeNode where NodeData == Void { extension RedBlackTreeNode: CustomDebugStringConvertible { var debugDescription: String { - return "[RedBlackTreeNode index=\(index) location=\(location) nodeTotalCount=\(nodeTotalCount)]" + "[RedBlackTreeNode index=\(index) location=\(location) nodeTotalCount=\(nodeTotalCount)]" } } diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index bbdce502e..3e3fdaf10 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -88,19 +88,19 @@ public extension Theme { } var markedTextBackgroundCornerRadius: CGFloat { - return 0 + 0 } func font(for highlightName: String) -> MultiPlatformFont? { - return nil + nil } func fontTraits(for highlightName: String) -> FontTraits { - return [] + [] } func shadow(for highlightName: String) -> NSShadow? { - return nil + nil } #if os(iOS) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift index 5bb612a07..645982190 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift @@ -2,11 +2,10 @@ import AppKit extension TextView: NSTextInputClient { - public override func doCommand(by selector: Selector) { + override public func doCommand(by selector: Selector) { #if DEBUG print(NSStringFromSelector(selector)) #endif - super.doCommand(by: selector) } public func insertText(_ string: Any, replacementRange: NSRange) { diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 714e135fd..f47454ce2 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -1,18 +1,20 @@ +// swiftlint:disable file_length #if os(macOS) import AppKit +// swiftlint:disable:next type_body_length open class TextView: NSView { public weak var editorDelegate: TextViewDelegate? - public override var acceptsFirstResponder: Bool { + override public var acceptsFirstResponder: Bool { true } - public override var isFlipped: Bool { + override public var isFlipped: Bool { true } /// A Boolean value that indicates whether the text view is editable. public var isEditable: Bool { get { - return textViewController.isEditable + textViewController.isEditable } set { if newValue != isEditable { @@ -27,7 +29,7 @@ open class TextView: NSView { /// The text that the text view displays. public var text: String { get { - return textViewController.text + textViewController.text } set { textViewController.text = newValue @@ -36,7 +38,7 @@ open class TextView: NSView { /// Colors and fonts to be used by the editor. public var theme: Theme { get { - return textViewController.theme + textViewController.theme } set { textViewController.theme = newValue @@ -47,7 +49,7 @@ open class TextView: NSView { /// Common usages of this includes the \" character to surround strings and { } to surround a scope. public var characterPairs: [CharacterPair] { get { - return textViewController.characterPairs + textViewController.characterPairs } set { textViewController.characterPairs = newValue @@ -56,7 +58,7 @@ open class TextView: NSView { /// Determines what should happen to the trailing component of a character pair when deleting the leading component. Defaults to `disabled` meaning that nothing will happen. public var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode { get { - return textViewController.characterPairTrailingComponentDeletionMode + textViewController.characterPairTrailingComponentDeletionMode } set { textViewController.characterPairTrailingComponentDeletionMode = newValue @@ -65,7 +67,7 @@ open class TextView: NSView { /// Enable to show line numbers in the gutter. public var showLineNumbers: Bool { get { - return textViewController.showLineNumbers + textViewController.showLineNumbers } set { textViewController.showLineNumbers = newValue @@ -74,7 +76,7 @@ open class TextView: NSView { /// Enable to show highlight the selected lines. The selection is only shown in the gutter when multiple lines are selected. public var lineSelectionDisplayType: LineSelectionDisplayType { get { - return textViewController.lineSelectionDisplayType + textViewController.lineSelectionDisplayType } set { textViewController.lineSelectionDisplayType = newValue @@ -83,7 +85,7 @@ open class TextView: NSView { /// The text view renders invisible tabs when enabled. The `tabsSymbol` is used to render tabs. public var showTabs: Bool { get { - return textViewController.showTabs + textViewController.showTabs } set { textViewController.showTabs = newValue @@ -94,7 +96,7 @@ open class TextView: NSView { /// The `spaceSymbol` is used to render spaces. public var showSpaces: Bool { get { - return textViewController.showSpaces + textViewController.showSpaces } set { textViewController.showSpaces = newValue @@ -105,7 +107,7 @@ open class TextView: NSView { /// The `nonBreakingSpaceSymbol` is used to render spaces. public var showNonBreakingSpaces: Bool { get { - return textViewController.showNonBreakingSpaces + textViewController.showNonBreakingSpaces } set { textViewController.showNonBreakingSpaces = newValue @@ -116,7 +118,7 @@ open class TextView: NSView { /// The `lineBreakSymbol` is used to render line breaks. public var showLineBreaks: Bool { get { - return textViewController.showLineBreaks + textViewController.showLineBreaks } set { textViewController.showLineBreaks = newValue @@ -127,7 +129,7 @@ open class TextView: NSView { /// The `softLineBreakSymbol` is used to render line breaks. These line breaks are typically represented by the U+2028 unicode character. Runestone does not provide any key commands for inserting these but supports rendering them. public var showSoftLineBreaks: Bool { get { - return textViewController.showSoftLineBreaks + textViewController.showSoftLineBreaks } set { textViewController.showSoftLineBreaks = newValue @@ -140,7 +142,7 @@ open class TextView: NSView { /// Common characters for this symbol include ▸, ⇥, ➜, ➞, and ❯. public var tabSymbol: String { get { - return textViewController.tabSymbol + textViewController.tabSymbol } set { textViewController.tabSymbol = newValue @@ -153,7 +155,7 @@ open class TextView: NSView { /// Common characters for this symbol include ·, •, and _. public var spaceSymbol: String { get { - return textViewController.spaceSymbol + textViewController.spaceSymbol } set { textViewController.spaceSymbol = newValue @@ -166,7 +168,7 @@ open class TextView: NSView { /// Common characters for this symbol include ·, •, and _. public var nonBreakingSpaceSymbol: String { get { - return textViewController.nonBreakingSpaceSymbol + textViewController.nonBreakingSpaceSymbol } set { textViewController.nonBreakingSpaceSymbol = newValue @@ -179,7 +181,7 @@ open class TextView: NSView { /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. public var lineBreakSymbol: String { get { - return textViewController.lineBreakSymbol + textViewController.lineBreakSymbol } set { textViewController.lineBreakSymbol = newValue @@ -192,7 +194,7 @@ open class TextView: NSView { /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. public var softLineBreakSymbol: String { get { - return textViewController.softLineBreakSymbol + textViewController.softLineBreakSymbol } set { textViewController.softLineBreakSymbol = newValue @@ -201,7 +203,7 @@ open class TextView: NSView { /// The strategy used when indenting text. public var indentStrategy: IndentStrategy { get { - return textViewController.indentStrategy + textViewController.indentStrategy } set { textViewController.indentStrategy = newValue @@ -210,7 +212,7 @@ open class TextView: NSView { /// The amount of padding before the line numbers inside the gutter. public var gutterLeadingPadding: CGFloat { get { - return textViewController.gutterLeadingPadding + textViewController.gutterLeadingPadding } set { textViewController.gutterLeadingPadding = newValue @@ -219,7 +221,7 @@ open class TextView: NSView { /// The amount of padding after the line numbers inside the gutter. public var gutterTrailingPadding: CGFloat { get { - return textViewController.gutterTrailingPadding + textViewController.gutterTrailingPadding } set { textViewController.gutterTrailingPadding = newValue @@ -228,7 +230,7 @@ open class TextView: NSView { /// The minimum amount of characters to use for width calculation inside the gutter. public var gutterMinimumCharacterCount: Int { get { - return textViewController.gutterMinimumCharacterCount + textViewController.gutterMinimumCharacterCount } set { textViewController.gutterMinimumCharacterCount = newValue @@ -237,7 +239,7 @@ open class TextView: NSView { /// The amount of spacing surrounding the lines. public var textContainerInset: NSEdgeInsets { get { - return textViewController.textContainerInset + textViewController.textContainerInset } set { textViewController.textContainerInset = newValue @@ -248,7 +250,7 @@ open class TextView: NSView { /// Line wrapping is enabled by default. public var isLineWrappingEnabled: Bool { get { - return textViewController.isLineWrappingEnabled + textViewController.isLineWrappingEnabled } set { textViewController.isLineWrappingEnabled = newValue @@ -257,7 +259,7 @@ open class TextView: NSView { /// Line break mode for text view. The default value is .byWordWrapping meaning that wrapping occurs on word boundaries. public var lineBreakMode: LineBreakMode { get { - return textViewController.lineBreakMode + textViewController.lineBreakMode } set { textViewController.lineBreakMode = newValue @@ -270,7 +272,7 @@ open class TextView: NSView { /// The line-height is multiplied with the value. public var lineHeightMultiplier: CGFloat { get { - return textViewController.lineHeightMultiplier + textViewController.lineHeightMultiplier } set { textViewController.lineHeightMultiplier = newValue @@ -279,7 +281,7 @@ open class TextView: NSView { /// The number of points by which to adjust kern. The default value is 0 meaning that kerning is disabled. public var kern: CGFloat { get { - return textViewController.kern + textViewController.kern } set { textViewController.kern = newValue @@ -288,7 +290,7 @@ open class TextView: NSView { /// The text view shows a page guide when enabled. Use `pageGuideColumn` to specify the location of the page guide. public var showPageGuide: Bool { get { - return textViewController.showPageGuide + textViewController.showPageGuide } set { textViewController.showPageGuide = newValue @@ -297,7 +299,7 @@ open class TextView: NSView { /// Specifies the location of the page guide. Use `showPageGuide` to specify if the page guide should be shown. public var pageGuideColumn: Int { get { - return textViewController.pageGuideColumn + textViewController.pageGuideColumn } set { textViewController.pageGuideColumn = newValue @@ -306,7 +308,7 @@ open class TextView: NSView { /// Automatically scrolls the text view to show the caret when typing or moving the caret. public var isAutomaticScrollEnabled: Bool { get { - return textViewController.isAutomaticScrollEnabled + textViewController.isAutomaticScrollEnabled } set { textViewController.isAutomaticScrollEnabled = newValue @@ -317,7 +319,7 @@ open class TextView: NSView { /// The overscroll is a factor of the scrollable area height and will not take into account any insets. 0 means no overscroll and 1 means an amount equal to the height of the text view. Detaults to 0. public var verticalOverscrollFactor: CGFloat { get { - return textViewController.verticalOverscrollFactor + textViewController.verticalOverscrollFactor } set { textViewController.verticalOverscrollFactor = newValue @@ -328,7 +330,7 @@ open class TextView: NSView { /// The overscroll is a factor of the scrollable area height and will not take into account any insets or the width of the gutter. 0 means no overscroll and 1 means an amount equal to the width of the text view. Detaults to 0. public var horizontalOverscrollFactor: CGFloat { get { - return textViewController.horizontalOverscrollFactor + textViewController.horizontalOverscrollFactor } set { textViewController.horizontalOverscrollFactor = newValue @@ -343,7 +345,7 @@ open class TextView: NSView { /// Ranges in the text to be highlighted. The color defined by the background will be drawen behind the text. public var highlightedRanges: [HighlightedRange] { get { - return textViewController.highlightedRanges + textViewController.highlightedRanges } set { textViewController.highlightedRanges = newValue @@ -352,7 +354,7 @@ open class TextView: NSView { /// Wheter the text view should loop when navigating through highlighted ranges using `selectPreviousHighlightedRange` or `selectNextHighlightedRange` on the text view. public var highlightedRangeLoopingMode: HighlightedRangeLoopingMode { get { - return textViewController.highlightedRangeLoopingMode + textViewController.highlightedRangeLoopingMode } set { textViewController.highlightedRangeLoopingMode = newValue @@ -365,7 +367,7 @@ open class TextView: NSView { /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)`` and ``TextView/replaceText(in:)``. public var lineEndings: LineEnding { get { - return textViewController.lineEndings + textViewController.lineEndings } set { textViewController.lineEndings = newValue @@ -389,7 +391,7 @@ open class TextView: NSView { } } } - open override var undoManager: UndoManager? { + override open var undoManager: UndoManager? { textViewController.timedUndoManager } @@ -449,7 +451,7 @@ open class TextView: NSView { setupScrollViewBoundsDidChangeObserver() } - required public init?(coder: NSCoder) { + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -487,7 +489,7 @@ open class TextView: NSView { return didResignFirstResponder } - public override func resizeSubviews(withOldSize oldSize: NSSize) { + override public func resizeSubviews(withOldSize oldSize: NSSize) { super.resizeSubviews(withOldSize: oldSize) scrollView.frame = bounds textViewController.viewport = CGRect(origin: scrollView.contentOffset, size: frame.size) @@ -497,17 +499,17 @@ open class TextView: NSView { updateCaretFrame() } - public override func layoutSubtreeIfNeeded() { + override public func layoutSubtreeIfNeeded() { super.layoutSubtreeIfNeeded() textViewController.layoutIfNeeded() } - public override func viewDidMoveToWindow() { + override public func viewDidMoveToWindow() { super.viewDidMoveToWindow() textViewController.performFullLayoutIfNeeded() } - public override func keyDown(with event: NSEvent) { + override public func keyDown(with event: NSEvent) { NSCursor.setHiddenUntilMouseMoves(true) let didInputContextHandleEvent = inputContext?.handleEvent(event) ?? false if !didInputContextHandleEvent { diff --git a/Sources/Runestone/TextView/Core/NavigationService.swift b/Sources/Runestone/TextView/Core/NavigationService.swift index 8ea255b1b..9644258ce 100644 --- a/Sources/Runestone/TextView/Core/NavigationService.swift +++ b/Sources/Runestone/TextView/Core/NavigationService.swift @@ -18,7 +18,7 @@ final class NavigationService { } var lineControllerStorage: LineControllerStorage { get { - return lineNavigationService.lineControllerStorage + lineNavigationService.lineControllerStorage } set { lineNavigationService.lineControllerStorage = newValue diff --git a/Sources/Runestone/TextView/Core/StringTokenizer.swift b/Sources/Runestone/TextView/Core/StringTokenizer.swift index 0dd1e18dc..10fcc2c1d 100644 --- a/Sources/Runestone/TextView/Core/StringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/StringTokenizer.swift @@ -267,7 +267,7 @@ private extension StringTokenizer { private extension CharacterSet { func contains(_ character: Character) -> Bool { - return character.unicodeScalars.allSatisfy(contains(_:)) + character.unicodeScalars.allSatisfy(contains(_:)) } } @@ -284,11 +284,9 @@ private extension StringView { private extension CharacterSet { func containsAllCharacters(of string: String) -> Bool { var containsAllCharacters = true - for char in string.unicodeScalars { - if !contains(char) { - containsAllCharacters = false - break - } + for char in string.unicodeScalars where !contains(char) { + containsAllCharacters = false + break } return containsAllCharacters } diff --git a/Sources/Runestone/TextView/Core/StringView.swift b/Sources/Runestone/TextView/Core/StringView.swift index 157e808f3..a1ee3c7c6 100644 --- a/Sources/Runestone/TextView/Core/StringView.swift +++ b/Sources/Runestone/TextView/Core/StringView.swift @@ -14,7 +14,7 @@ final class StringViewBytesResult { final class StringView { var string: NSString { get { - return internalString + internalString } set { internalString = NSMutableString(string: newValue) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift index 300bcf7bd..ecc82dd7d 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift @@ -34,8 +34,8 @@ extension TextViewController { #if os(macOS) scrollView.hasVerticalScroller = scrollView.contentSize.height > scrollView.frame.height scrollView.hasHorizontalScroller = scrollView.contentSize.width > scrollView.frame.width - scrollView.horizontalScroller?.layer?.zPosition = 1000 - scrollView.verticalScroller?.layer?.zPosition = 1000 + scrollView.horizontalScroller?.layer?.zPosition = 1_000 + scrollView.verticalScroller?.layer?.zPosition = 1_000 #endif } } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift index c6b233564..daaf33ce0 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Layout.swift @@ -13,7 +13,7 @@ extension TextViewController { performFullLayout() } } - + func layoutIfNeeded() { layoutManager.layoutIfNeeded() layoutManager.layoutLineSelectionIfNeeded() diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift index 29385f6ff..c8d932a8d 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift @@ -62,7 +62,7 @@ extension TextViewController { } private extension TextViewController { - private func move(by granularity: TextGranularity, inDirection directon: TextDirection) { + private func move(by granularity: TextGranularity, inDirection directon: TextDirection) { if let currentlySelectedRange = selectedRange { selectedRange = selectionService.range(movingFrom: currentlySelectedRange, by: granularity, inDirection: directon) } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 8200e3b2e..97e86117d 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length import Combine import Foundation @@ -10,6 +11,7 @@ extension TextViewControllerDelegate { func textViewController(_ textViewController: TextViewController, didChangeSelectedRange selectedRange: NSRange?) {} } +// swiftlint:disable:next type_body_length final class TextViewController { weak var delegate: TextViewControllerDelegate? var textView: TextView { @@ -30,7 +32,7 @@ final class TextViewController { private weak var _scrollView: MultiPlatformScrollView? var selectedRange: NSRange? { get { - return _selectedRange + _selectedRange } set { if newValue != _selectedRange { @@ -78,7 +80,7 @@ final class TextViewController { } var viewport: CGRect { get { - return layoutManager.viewport + layoutManager.viewport } set { if newValue != layoutManager.viewport { @@ -90,7 +92,7 @@ final class TextViewController { } var text: String { get { - return stringView.string as String + stringView.string as String } set { let nsString = newValue as NSString @@ -176,7 +178,7 @@ final class TextViewController { let pageGuideController = PageGuideController() let highlightNavigationController = HighlightNavigationController() let timedUndoManager = TimedUndoManager() - + var languageMode: InternalLanguageMode = PlainTextInternalLanguageMode() { didSet { if languageMode !== oldValue { @@ -217,7 +219,7 @@ final class TextViewController { } var lineSelectionDisplayType: LineSelectionDisplayType { get { - return layoutManager.lineSelectionDisplayType + layoutManager.lineSelectionDisplayType } set { layoutManager.lineSelectionDisplayType = newValue @@ -225,7 +227,7 @@ final class TextViewController { } var showTabs: Bool { get { - return invisibleCharacterConfiguration.showTabs + invisibleCharacterConfiguration.showTabs } set { if newValue != invisibleCharacterConfiguration.showTabs { @@ -236,7 +238,7 @@ final class TextViewController { } var showSpaces: Bool { get { - return invisibleCharacterConfiguration.showSpaces + invisibleCharacterConfiguration.showSpaces } set { if newValue != invisibleCharacterConfiguration.showSpaces { @@ -247,7 +249,7 @@ final class TextViewController { } var showNonBreakingSpaces: Bool { get { - return invisibleCharacterConfiguration.showNonBreakingSpaces + invisibleCharacterConfiguration.showNonBreakingSpaces } set { if newValue != invisibleCharacterConfiguration.showNonBreakingSpaces { @@ -258,7 +260,7 @@ final class TextViewController { } var showLineBreaks: Bool { get { - return invisibleCharacterConfiguration.showLineBreaks + invisibleCharacterConfiguration.showLineBreaks } set { if newValue != invisibleCharacterConfiguration.showLineBreaks { @@ -272,7 +274,7 @@ final class TextViewController { } var showSoftLineBreaks: Bool { get { - return invisibleCharacterConfiguration.showSoftLineBreaks + invisibleCharacterConfiguration.showSoftLineBreaks } set { if newValue != invisibleCharacterConfiguration.showSoftLineBreaks { @@ -286,7 +288,7 @@ final class TextViewController { } var tabSymbol: String { get { - return invisibleCharacterConfiguration.tabSymbol + invisibleCharacterConfiguration.tabSymbol } set { if newValue != invisibleCharacterConfiguration.tabSymbol { @@ -297,7 +299,7 @@ final class TextViewController { } var spaceSymbol: String { get { - return invisibleCharacterConfiguration.spaceSymbol + invisibleCharacterConfiguration.spaceSymbol } set { if newValue != invisibleCharacterConfiguration.spaceSymbol { @@ -308,7 +310,7 @@ final class TextViewController { } var nonBreakingSpaceSymbol: String { get { - return invisibleCharacterConfiguration.nonBreakingSpaceSymbol + invisibleCharacterConfiguration.nonBreakingSpaceSymbol } set { if newValue != invisibleCharacterConfiguration.nonBreakingSpaceSymbol { @@ -319,7 +321,7 @@ final class TextViewController { } var lineBreakSymbol: String { get { - return invisibleCharacterConfiguration.lineBreakSymbol + invisibleCharacterConfiguration.lineBreakSymbol } set { if newValue != invisibleCharacterConfiguration.lineBreakSymbol { @@ -330,7 +332,7 @@ final class TextViewController { } var softLineBreakSymbol: String { get { - return invisibleCharacterConfiguration.softLineBreakSymbol + invisibleCharacterConfiguration.softLineBreakSymbol } set { if newValue != invisibleCharacterConfiguration.softLineBreakSymbol { @@ -378,7 +380,7 @@ final class TextViewController { } var textContainerInset: MultiPlatformEdgeInsets { get { - return layoutManager.textContainerInset + layoutManager.textContainerInset } set { if newValue != layoutManager.textContainerInset { @@ -391,7 +393,7 @@ final class TextViewController { } var isLineWrappingEnabled: Bool { get { - return layoutManager.isLineWrappingEnabled + layoutManager.isLineWrappingEnabled } set { if newValue != layoutManager.isLineWrappingEnabled { @@ -458,7 +460,7 @@ final class TextViewController { } var pageGuideColumn: Int { get { - return pageGuideController.column + pageGuideController.column } set { if newValue != pageGuideController.column { @@ -469,7 +471,7 @@ final class TextViewController { } var verticalOverscrollFactor: CGFloat { get { - return contentSizeService.verticalOverscrollFactor + contentSizeService.verticalOverscrollFactor } set { if newValue != contentSizeService.verticalOverscrollFactor { @@ -480,7 +482,7 @@ final class TextViewController { } var horizontalOverscrollFactor: CGFloat { get { - return contentSizeService.horizontalOverscrollFactor + contentSizeService.horizontalOverscrollFactor } set { if newValue != contentSizeService.horizontalOverscrollFactor { @@ -494,7 +496,7 @@ final class TextViewController { } var highlightedRanges: [HighlightedRange] { get { - return highlightService.highlightedRanges + highlightService.highlightedRanges } set { if newValue != highlightService.highlightedRanges { @@ -532,6 +534,7 @@ final class TextViewController { } private var cancellables: Set = [] + // swiftlint:disable:next function_body_length init(textView: TextView, scrollView: MultiPlatformScrollView) { _textView = textView _scrollView = scrollView @@ -645,7 +648,7 @@ final class TextViewController { } func highlightedRange(for range: NSRange) -> HighlightedRange? { - highlightedRanges.first(where: { $0.range == selectedRange }) + highlightedRanges.first { $0.range == selectedRange } } } diff --git a/Sources/Runestone/TextView/Core/TextViewDelegate.swift b/Sources/Runestone/TextView/Core/TextViewDelegate.swift index 99ab65ec5..c096cdc7d 100644 --- a/Sources/Runestone/TextView/Core/TextViewDelegate.swift +++ b/Sources/Runestone/TextView/Core/TextViewDelegate.swift @@ -105,11 +105,11 @@ public protocol TextViewDelegate: AnyObject { public extension TextViewDelegate { func textViewShouldBeginEditing(_ textView: TextView) -> Bool { - return true + true } func textViewShouldEndEditing(_ textView: TextView) -> Bool { - return true + true } func textViewDidBeginEditing(_ textView: TextView) {} @@ -121,15 +121,15 @@ public extension TextViewDelegate { func textViewDidChangeSelection(_ textView: TextView) {} func textView(_ textView: TextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - return true + true } func textView(_ textView: TextView, shouldInsert characterPair: CharacterPair, in range: NSRange) -> Bool { - return true + true } func textView(_ textView: TextView, shouldSkipTrailingComponentOf characterPair: CharacterPair, in range: NSRange) -> Bool { - return true + true } func textViewDidChangeGutterWidth(_ textView: TextView) {} @@ -143,7 +143,7 @@ public extension TextViewDelegate { func textViewDidLoopToFirstHighlightedRange(_ textView: TextView) {} func textView(_ textView: TextView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - return false + false } func textView(_ textView: TextView, replaceTextIn highlightedRange: HighlightedRange) {} diff --git a/Sources/Runestone/TextView/Core/TimedUndoManager.swift b/Sources/Runestone/TextView/Core/TimedUndoManager.swift index af5610a7b..01493641c 100644 --- a/Sources/Runestone/TextView/Core/TimedUndoManager.swift +++ b/Sources/Runestone/TextView/Core/TimedUndoManager.swift @@ -4,7 +4,7 @@ final class TimedUndoManager: UndoManager { private let endGroupingInterval: TimeInterval = 1 private var endGroupingTimer: Timer? private var hasOpenGroup: Bool { - return groupingLevel > 0 + groupingLevel > 0 } override init() { diff --git a/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift b/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift index 60ee3e08b..c16ae2654 100644 --- a/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift +++ b/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift @@ -66,15 +66,15 @@ private extension EditMenuController { } private func highlightedRange(for range: NSRange) -> HighlightedRange? { - return delegate?.editMenuController(self, highlightedRangeFor: range) + delegate?.editMenuController(self, highlightedRangeFor: range) } private func canReplaceText(in highlightedRange: HighlightedRange) -> Bool { - return delegate?.editMenuController(self, canReplaceTextIn: highlightedRange) ?? false + delegate?.editMenuController(self, canReplaceTextIn: highlightedRange) ?? false } private func caretRect(at location: Int) -> CGRect { - return delegate?.editMenuController(self, caretRectAt: location) ?? .zero + delegate?.editMenuController(self, caretRectAt: location) ?? .zero } private func replaceActionIfAvailable(for range: NSRange) -> UIAction? { diff --git a/Sources/Runestone/TextView/Core/iOS/IndexedRange.swift b/Sources/Runestone/TextView/Core/iOS/IndexedRange.swift index 07b772935..89257144e 100644 --- a/Sources/Runestone/TextView/Core/iOS/IndexedRange.swift +++ b/Sources/Runestone/TextView/Core/iOS/IndexedRange.swift @@ -4,13 +4,13 @@ import UIKit final class IndexedRange: UITextRange { let range: NSRange override var start: UITextPosition { - return IndexedPosition(index: range.location) + IndexedPosition(index: range.location) } override var end: UITextPosition { - return IndexedPosition(index: range.location + range.length) + IndexedPosition(index: range.location + range.length) } override var isEmpty: Bool { - return range.length == 0 + range.length == 0 } init(_ range: NSRange) { diff --git a/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift index f34504c7e..eda780661 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift @@ -4,7 +4,7 @@ import UIKit final class TextInputStringTokenizer: UITextInputStringTokenizer { var lineManager: LineManager { get { - return stringTokenizer.lineManager + stringTokenizer.lineManager } set { stringTokenizer.lineManager = newValue @@ -12,7 +12,7 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { } var stringView: StringView { get { - return stringTokenizer.stringView + stringTokenizer.stringView } set { stringTokenizer.stringView = newValue @@ -51,7 +51,7 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { withinTextUnit granularity: UITextGranularity, inDirection direction: UITextDirection ) -> Bool { - return super.isPosition(position, withinTextUnit: granularity, inDirection: direction) + super.isPosition(position, withinTextUnit: granularity, inDirection: direction) } override func position( @@ -77,7 +77,7 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { with granularity: UITextGranularity, inDirection direction: UITextDirection ) -> UITextRange? { - return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) + super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) } } @@ -110,7 +110,7 @@ private extension StringTokenizer.Direction { private extension UITextDirection { var isForward: Bool { - return rawValue == UITextStorageDirection.forward.rawValue + rawValue == UITextStorageDirection.forward.rawValue || rawValue == UITextLayoutDirection.right.rawValue || rawValue == UITextLayoutDirection.down.rawValue } diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index 987c7feb0..2385d1961 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -1,3 +1,4 @@ +// swiftlint:disable file_length #if os(iOS) import UIKit @@ -229,10 +230,12 @@ public extension TextView { // MARK: - Marking public extension TextView { - var markedTextStyle: [NSAttributedString.Key : Any]? { - get { return nil } + // swiftlint:disable unused_setter_value + var markedTextStyle: [NSAttributedString.Key: Any]? { + get { nil } set {} } + // swiftlint:enable unused_setter_value var markedTextRange: UITextRange? { get { diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 9188e0e5c..bbaf2bc91 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -15,7 +15,7 @@ open class TextView: UIScrollView { @objc public weak var inputDelegate: UITextInputDelegate? /// Returns a Boolean value indicating whether this object can become the first responder. override public var canBecomeFirstResponder: Bool { - return !isFirstResponder && isEditable + !isFirstResponder && isEditable } /// Delegate to receive callbacks for events triggered by the editor. public weak var editorDelegate: TextViewDelegate? @@ -26,7 +26,7 @@ open class TextView: UIScrollView { /// The text that the text view displays. public var text: String { get { - return textViewController.text + textViewController.text } set { textViewController.text = newValue @@ -35,7 +35,7 @@ open class TextView: UIScrollView { /// A Boolean value that indicates whether the text view is editable. public var isEditable: Bool { get { - return textViewController.isEditable + textViewController.isEditable } set { if newValue != isEditable { @@ -49,7 +49,7 @@ open class TextView: UIScrollView { /// A Boolean value that indicates whether the text view is selectable. public var isSelectable: Bool { get { - return textViewController.isSelectable + textViewController.isSelectable } set { if newValue != isSelectable { @@ -79,7 +79,7 @@ open class TextView: UIScrollView { /// Colors and fonts to be used by the editor. public var theme: Theme { get { - return textViewController.theme + textViewController.theme } set { textViewController.theme = newValue @@ -144,7 +144,7 @@ open class TextView: UIScrollView { /// Common usages of this includes the \" character to surround strings and { } to surround a scope. public var characterPairs: [CharacterPair] { get { - return textViewController.characterPairs + textViewController.characterPairs } set { textViewController.characterPairs = newValue @@ -153,7 +153,7 @@ open class TextView: UIScrollView { /// Determines what should happen to the trailing component of a character pair when deleting the leading component. Defaults to `disabled` meaning that nothing will happen. public var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode { get { - return textViewController.characterPairTrailingComponentDeletionMode + textViewController.characterPairTrailingComponentDeletionMode } set { textViewController.characterPairTrailingComponentDeletionMode = newValue @@ -162,7 +162,7 @@ open class TextView: UIScrollView { /// Enable to show line numbers in the gutter. public var showLineNumbers: Bool { get { - return textViewController.showLineNumbers + textViewController.showLineNumbers } set { textViewController.showLineNumbers = newValue @@ -171,7 +171,7 @@ open class TextView: UIScrollView { /// Enable to show highlight the selected lines. The selection is only shown in the gutter when multiple lines are selected. public var lineSelectionDisplayType: LineSelectionDisplayType { get { - return textViewController.lineSelectionDisplayType + textViewController.lineSelectionDisplayType } set { textViewController.lineSelectionDisplayType = newValue @@ -180,7 +180,7 @@ open class TextView: UIScrollView { /// The text view renders invisible tabs when enabled. The `tabsSymbol` is used to render tabs. public var showTabs: Bool { get { - return textViewController.showTabs + textViewController.showTabs } set { textViewController.showTabs = newValue @@ -191,7 +191,7 @@ open class TextView: UIScrollView { /// The `spaceSymbol` is used to render spaces. public var showSpaces: Bool { get { - return textViewController.showSpaces + textViewController.showSpaces } set { textViewController.showSpaces = newValue @@ -202,7 +202,7 @@ open class TextView: UIScrollView { /// The `nonBreakingSpaceSymbol` is used to render spaces. public var showNonBreakingSpaces: Bool { get { - return textViewController.showNonBreakingSpaces + textViewController.showNonBreakingSpaces } set { textViewController.showNonBreakingSpaces = newValue @@ -213,7 +213,7 @@ open class TextView: UIScrollView { /// The `lineBreakSymbol` is used to render line breaks. public var showLineBreaks: Bool { get { - return textViewController.showLineBreaks + textViewController.showLineBreaks } set { textViewController.showLineBreaks = newValue @@ -224,7 +224,7 @@ open class TextView: UIScrollView { /// The `softLineBreakSymbol` is used to render line breaks. These line breaks are typically represented by the U+2028 unicode character. Runestone does not provide any key commands for inserting these but supports rendering them. public var showSoftLineBreaks: Bool { get { - return textViewController.showSoftLineBreaks + textViewController.showSoftLineBreaks } set { textViewController.showSoftLineBreaks = newValue @@ -237,7 +237,7 @@ open class TextView: UIScrollView { /// Common characters for this symbol include ▸, ⇥, ➜, ➞, and ❯. public var tabSymbol: String { get { - return textViewController.tabSymbol + textViewController.tabSymbol } set { textViewController.tabSymbol = newValue @@ -250,7 +250,7 @@ open class TextView: UIScrollView { /// Common characters for this symbol include ·, •, and _. public var spaceSymbol: String { get { - return textViewController.spaceSymbol + textViewController.spaceSymbol } set { textViewController.spaceSymbol = newValue @@ -263,7 +263,7 @@ open class TextView: UIScrollView { /// Common characters for this symbol include ·, •, and _. public var nonBreakingSpaceSymbol: String { get { - return textViewController.nonBreakingSpaceSymbol + textViewController.nonBreakingSpaceSymbol } set { textViewController.nonBreakingSpaceSymbol = newValue @@ -276,7 +276,7 @@ open class TextView: UIScrollView { /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. public var lineBreakSymbol: String { get { - return textViewController.lineBreakSymbol + textViewController.lineBreakSymbol } set { textViewController.lineBreakSymbol = newValue @@ -289,7 +289,7 @@ open class TextView: UIScrollView { /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. public var softLineBreakSymbol: String { get { - return textViewController.softLineBreakSymbol + textViewController.softLineBreakSymbol } set { textViewController.softLineBreakSymbol = newValue @@ -298,7 +298,7 @@ open class TextView: UIScrollView { /// The strategy used when indenting text. public var indentStrategy: IndentStrategy { get { - return textViewController.indentStrategy + textViewController.indentStrategy } set { textViewController.indentStrategy = newValue @@ -307,7 +307,7 @@ open class TextView: UIScrollView { /// The amount of padding before the line numbers inside the gutter. public var gutterLeadingPadding: CGFloat { get { - return textViewController.gutterLeadingPadding + textViewController.gutterLeadingPadding } set { textViewController.gutterLeadingPadding = newValue @@ -316,7 +316,7 @@ open class TextView: UIScrollView { /// The amount of padding after the line numbers inside the gutter. public var gutterTrailingPadding: CGFloat { get { - return textViewController.gutterTrailingPadding + textViewController.gutterTrailingPadding } set { textViewController.gutterTrailingPadding = newValue @@ -325,7 +325,7 @@ open class TextView: UIScrollView { /// The minimum amount of characters to use for width calculation inside the gutter. public var gutterMinimumCharacterCount: Int { get { - return textViewController.gutterMinimumCharacterCount + textViewController.gutterMinimumCharacterCount } set { textViewController.gutterMinimumCharacterCount = newValue @@ -334,7 +334,7 @@ open class TextView: UIScrollView { /// The amount of spacing surrounding the lines. public var textContainerInset: UIEdgeInsets { get { - return textViewController.textContainerInset + textViewController.textContainerInset } set { textViewController.textContainerInset = newValue @@ -345,7 +345,7 @@ open class TextView: UIScrollView { /// Line wrapping is enabled by default. public var isLineWrappingEnabled: Bool { get { - return textViewController.isLineWrappingEnabled + textViewController.isLineWrappingEnabled } set { textViewController.isLineWrappingEnabled = newValue @@ -354,7 +354,7 @@ open class TextView: UIScrollView { /// Line break mode for text view. The default value is .byWordWrapping meaning that wrapping occurs on word boundaries. public var lineBreakMode: LineBreakMode { get { - return textViewController.lineBreakMode + textViewController.lineBreakMode } set { textViewController.lineBreakMode = newValue @@ -367,7 +367,7 @@ open class TextView: UIScrollView { /// The line-height is multiplied with the value. public var lineHeightMultiplier: CGFloat { get { - return textViewController.lineHeightMultiplier + textViewController.lineHeightMultiplier } set { textViewController.lineHeightMultiplier = newValue @@ -376,7 +376,7 @@ open class TextView: UIScrollView { /// The number of points by which to adjust kern. The default value is 0 meaning that kerning is disabled. public var kern: CGFloat { get { - return textViewController.kern + textViewController.kern } set { textViewController.kern = newValue @@ -385,7 +385,7 @@ open class TextView: UIScrollView { /// The text view shows a page guide when enabled. Use `pageGuideColumn` to specify the location of the page guide. public var showPageGuide: Bool { get { - return textViewController.showPageGuide + textViewController.showPageGuide } set { textViewController.showPageGuide = newValue @@ -394,7 +394,7 @@ open class TextView: UIScrollView { /// Specifies the location of the page guide. Use `showPageGuide` to specify if the page guide should be shown. public var pageGuideColumn: Int { get { - return textViewController.pageGuideColumn + textViewController.pageGuideColumn } set { textViewController.pageGuideColumn = newValue @@ -403,7 +403,7 @@ open class TextView: UIScrollView { /// Automatically scrolls the text view to show the caret when typing or moving the caret. public var isAutomaticScrollEnabled: Bool { get { - return textViewController.isAutomaticScrollEnabled + textViewController.isAutomaticScrollEnabled } set { textViewController.isAutomaticScrollEnabled = newValue @@ -414,7 +414,7 @@ open class TextView: UIScrollView { /// The overscroll is a factor of the scrollable area height and will not take into account any insets. 0 means no overscroll and 1 means an amount equal to the height of the text view. Detaults to 0. public var verticalOverscrollFactor: CGFloat { get { - return textViewController.verticalOverscrollFactor + textViewController.verticalOverscrollFactor } set { textViewController.verticalOverscrollFactor = newValue @@ -425,7 +425,7 @@ open class TextView: UIScrollView { /// The overscroll is a factor of the scrollable area height and will not take into account any insets or the width of the gutter. 0 means no overscroll and 1 means an amount equal to the width of the text view. Detaults to 0. public var horizontalOverscrollFactor: CGFloat { get { - return textViewController.horizontalOverscrollFactor + textViewController.horizontalOverscrollFactor } set { textViewController.horizontalOverscrollFactor = newValue @@ -440,7 +440,7 @@ open class TextView: UIScrollView { /// Ranges in the text to be highlighted. The color defined by the background will be drawen behind the text. public var highlightedRanges: [HighlightedRange] { get { - return textViewController.highlightedRanges + textViewController.highlightedRanges } set { textViewController.highlightedRanges = newValue @@ -449,7 +449,7 @@ open class TextView: UIScrollView { /// Wheter the text view should loop when navigating through highlighted ranges using `selectPreviousHighlightedRange` or `selectNextHighlightedRange` on the text view. public var highlightedRangeLoopingMode: HighlightedRangeLoopingMode { get { - return textViewController.highlightedRangeLoopingMode + textViewController.highlightedRangeLoopingMode } set { textViewController.highlightedRangeLoopingMode = newValue @@ -462,7 +462,7 @@ open class TextView: UIScrollView { /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)`` and ``TextView/replaceText(in:)``. public var lineEndings: LineEnding { get { - return textViewController.lineEndings + textViewController.lineEndings } set { textViewController.lineEndings = newValue @@ -476,7 +476,7 @@ open class TextView: UIScrollView { @available(iOS 16, *) public var isFindInteractionEnabled: Bool { get { - return textSearchingHelper.isFindInteractionEnabled + textSearchingHelper.isFindInteractionEnabled } set { textSearchingHelper.isFindInteractionEnabled = newValue @@ -512,7 +512,7 @@ open class TextView: UIScrollView { lineManager: textViewController.lineManager, lineControllerStorage: textViewController.lineControllerStorage ) - + var isRestoringPreviouslyDeletedText = false var hasDeletedTextWithPendingLayoutSubviews = false var notifyInputDelegateAboutSelectionChangeInLayoutSubviews = false @@ -556,7 +556,7 @@ open class TextView: UIScrollView { /// Create a new text view. /// - Parameter frame: The frame rectangle of the text view. - public override init(frame: CGRect) { + override public init(frame: CGRect) { super.init(frame: frame) textViewController.delegate = self backgroundColor = .white @@ -580,18 +580,18 @@ open class TextView: UIScrollView { /// The initializer has not been implemented. /// - Parameter coder: Not used. - required public init?(coder: NSCoder) { + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } /// Tells the view that its window object changed. - open override func didMoveToWindow() { + override open func didMoveToWindow() { super.didMoveToWindow() textViewController.performFullLayoutIfNeeded() } /// Lays out subviews. - open override func layoutSubviews() { + override open func layoutSubviews() { super.layoutSubviews() hasDeletedTextWithPendingLayoutSubviews = false textViewController.scrollViewSize = frame.size @@ -658,7 +658,7 @@ open class TextView: UIScrollView { /// Copy the selected text. /// /// - Parameter sender: The object calling this method. - open override func copy(_ sender: Any?) { + override open func copy(_ sender: Any?) { if let selectedTextRange = selectedTextRange, let text = text(in: selectedTextRange) { UIPasteboard.general.string = text } @@ -667,7 +667,7 @@ open class TextView: UIScrollView { /// Paste text from the pasteboard. /// /// - Parameter sender: The object calling this method. - open override func paste(_ sender: Any?) { + override open func paste(_ sender: Any?) { if let selectedTextRange = selectedTextRange, let string = UIPasteboard.general.string { inputDelegate?.selectionWillChange(self) let preparedText = textViewController.prepareTextForInsertion(string) @@ -679,7 +679,7 @@ open class TextView: UIScrollView { /// Cut text to the pasteboard. /// /// - Parameter sender: The object calling this method. - open override func cut(_ sender: Any?) { + override open func cut(_ sender: Any?) { if let selectedTextRange = selectedTextRange, let text = text(in: selectedTextRange) { UIPasteboard.general.string = text replace(selectedTextRange, withText: "") @@ -689,7 +689,7 @@ open class TextView: UIScrollView { /// Select all text in the text view. /// /// - Parameter sender: The object calling this method. - open override func selectAll(_ sender: Any?) { + override open func selectAll(_ sender: Any?) { notifyInputDelegateAboutSelectionChangeInLayoutSubviews = true selectedRange = NSRange(location: 0, length: textViewController.stringView.string.length) } @@ -714,7 +714,7 @@ open class TextView: UIScrollView { /// - action: A selector that identifies a method associated with a command. /// - sender: The object calling this method. /// - Returns: true if the command identified by action should be enabled or false if it should be disabled. - open override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + override open func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { if action == #selector(copy(_:)) { if let selectedTextRange = selectedTextRange { return !selectedTextRange.isEmpty @@ -830,7 +830,7 @@ open class TextView: UIScrollView { inputDelegate?.textDidChange(self) } } - + /// Increases the indentation level of the selected lines. public func shiftRight() { if let selectedRange = textViewController.selectedRange { @@ -857,7 +857,7 @@ open class TextView: UIScrollView { /// Attempts to detect the indent strategy used in the document. This may return an unknown strategy even /// when the document contains indentation. public func detectIndentStrategy() -> DetectedIndentStrategy { - return textViewController.languageMode.detectIndentStrategy() + textViewController.languageMode.detectIndentStrategy() } /// Go to the beginning of the line at the specified index. @@ -975,7 +975,7 @@ open class TextView: UIScrollView { } /// Called when the iOS interface environment changes. - open override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { textViewController.invalidateLines() @@ -988,7 +988,7 @@ open class TextView: UIScrollView { /// - point: A point specified in the receiver’s local coordinate system (bounds). /// - event: The event that warranted a call to this method. If you are calling this method from outside your event-handling code, you may specify nil. /// - Returns: The view object that is the farthest descendent of the current view and contains point. Returns nil if the point lies completely outside the receiver’s view hierarchy. - open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + override open func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard isSelectable else { return nil } @@ -1004,7 +1004,7 @@ open class TextView: UIScrollView { /// - Parameters: /// - presses: A set of UIPress instances that represent the buttons that the user is no longer pressing. /// - event: The event to which the presses belong. - open override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { + override open func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { super.pressesEnded(presses, with: event) if let keyCode = presses.first?.key?.keyCode, presses.count == 1, textViewController.markedRange != nil { handleKeyPressDuringMultistageTextInput(keyCode: keyCode) @@ -1014,7 +1014,7 @@ open class TextView: UIScrollView { extension TextView { var viewHierarchyContainsCaret: Bool { - return textSelectionView?.subviews.count == 1 + textSelectionView?.subviews.count == 1 } var textSelectionView: UIView? { if let klass = NSClassFromString("UITextSelectionView") { @@ -1090,7 +1090,7 @@ private extension TextView { installNonEditableInteraction() editorDelegate?.textViewDidEndEditing(self) } - + private func installEditableInteraction() { if editableTextInteraction.view == nil { isInputAccessoryViewEnabled = true diff --git a/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift b/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift index c3a183527..3d44e2c09 100644 --- a/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift +++ b/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift @@ -10,7 +10,7 @@ final class GutterBackgroundView: MultiPlatformView { } var hairlineColor: MultiPlatformColor? { get { - return hairlineView.backgroundColor + hairlineView.backgroundColor } set { hairlineView.backgroundColor = newValue diff --git a/Sources/Runestone/TextView/Highlight/HighlightedRange.swift b/Sources/Runestone/TextView/Highlight/HighlightedRange.swift index f1c99b22c..49ceddebf 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightedRange.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightedRange.swift @@ -27,12 +27,12 @@ public final class HighlightedRange { extension HighlightedRange: Equatable { public static func == (lhs: HighlightedRange, rhs: HighlightedRange) -> Bool { - return lhs.id == rhs.id && lhs.range == rhs.range && lhs.color == rhs.color + lhs.id == rhs.id && lhs.range == rhs.range && lhs.color == rhs.color } } extension HighlightedRange: CustomDebugStringConvertible { public var debugDescription: String { - return "[HighightedRange range=\(range)]" + "[HighightedRange range=\(range)]" } } diff --git a/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift b/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift index b8e262c57..166dbee10 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift @@ -18,7 +18,7 @@ final class HighlightedRangeFragment: Equatable { extension HighlightedRangeFragment { static func == (lhs: HighlightedRangeFragment, rhs: HighlightedRangeFragment) -> Bool { - return lhs.range == rhs.range + lhs.range == rhs.range && lhs.containsStart == rhs.containsStart && lhs.containsEnd == rhs.containsEnd && lhs.color == rhs.color diff --git a/Sources/Runestone/TextView/Indent/IndentController.swift b/Sources/Runestone/TextView/Indent/IndentController.swift index ecd2326ef..e7131f906 100644 --- a/Sources/Runestone/TextView/Indent/IndentController.swift +++ b/Sources/Runestone/TextView/Indent/IndentController.swift @@ -52,7 +52,13 @@ final class IndentController { private var _tabWidth: CGFloat? - init(stringView: StringView, lineManager: LineManager, languageMode: InternalLanguageMode, indentStrategy: IndentStrategy, indentFont: MultiPlatformFont) { + init( + stringView: StringView, + lineManager: LineManager, + languageMode: InternalLanguageMode, + indentStrategy: IndentStrategy, + indentFont: MultiPlatformFont + ) { self.stringView = stringView self.lineManager = lineManager self.languageMode = languageMode diff --git a/Sources/Runestone/TextView/LineController/LineController.swift b/Sources/Runestone/TextView/LineController/LineController.swift index d47ff4040..f7585f70c 100644 --- a/Sources/Runestone/TextView/LineController/LineController.swift +++ b/Sources/Runestone/TextView/LineController/LineController.swift @@ -41,14 +41,14 @@ final class LineController { var tabWidth: CGFloat = 10 var constrainingWidth: CGFloat { get { - return typesetter.constrainingWidth + typesetter.constrainingWidth } set { typesetter.constrainingWidth = newValue } } var lineWidth: CGFloat { - return ceil(typesetter.maximumLineWidth) + ceil(typesetter.maximumLineWidth) } var lineHeight: CGFloat { if let lineHeight = _lineHeight { @@ -76,17 +76,17 @@ final class LineController { } var lineBreakMode: LineBreakMode { get { - return typesetter.lineBreakMode + typesetter.lineBreakMode } set { typesetter.lineBreakMode = newValue } } var numberOfLineFragments: Int { - return typesetter.lineFragments.count + typesetter.lineFragments.count } var isFinishedTypesetting: Bool { - return typesetter.isFinishedTypesetting + typesetter.isFinishedTypesetting } private(set) var attributedString: NSMutableAttributedString? @@ -169,11 +169,11 @@ final class LineController { } func lineFragmentNode(containingCharacterAt location: Int) -> LineFragmentNode? { - return lineFragmentTree.node(containingLocation: location) + lineFragmentTree.node(containingLocation: location) } func lineFragmentNode(atIndex index: Int) -> LineFragmentNode { - return lineFragmentTree.node(atIndex: index) + lineFragmentTree.node(atIndex: index) } func setNeedsDisplayOnLineFragmentViews() { diff --git a/Sources/Runestone/TextView/LineController/LineControllerFactory.swift b/Sources/Runestone/TextView/LineController/LineControllerFactory.swift index 03a6c4967..a7111ef66 100644 --- a/Sources/Runestone/TextView/LineController/LineControllerFactory.swift +++ b/Sources/Runestone/TextView/LineController/LineControllerFactory.swift @@ -11,9 +11,9 @@ final class LineControllerFactory { } func makeLineController(for line: DocumentLineNode) -> LineController { - return LineController(line: line, - stringView: stringView, - invisibleCharacterConfiguration: invisibleCharacterConfiguration, - highlightService: highlightService) + LineController(line: line, + stringView: stringView, + invisibleCharacterConfiguration: invisibleCharacterConfiguration, + highlightService: highlightService) } } diff --git a/Sources/Runestone/TextView/LineController/LineControllerStorage.swift b/Sources/Runestone/TextView/LineController/LineControllerStorage.swift index ab9e68ea9..8e0a6090f 100644 --- a/Sources/Runestone/TextView/LineController/LineControllerStorage.swift +++ b/Sources/Runestone/TextView/LineController/LineControllerStorage.swift @@ -5,7 +5,7 @@ protocol LineControllerStorageDelegate: AnyObject { final class LineControllerStorage { weak var delegate: LineControllerStorageDelegate? subscript(_ lineID: DocumentLineNodeID) -> LineController? { - return lineControllers[lineID] + lineControllers[lineID] } var stringView: StringView { @@ -17,7 +17,7 @@ final class LineControllerStorage { } fileprivate var numberOfLineControllers: Int { - return lineControllers.count + lineControllers.count } private var lineControllers: [DocumentLineNodeID: LineController] = [:] diff --git a/Sources/Runestone/TextView/LineController/LineFragment.swift b/Sources/Runestone/TextView/LineController/LineFragment.swift index 22ebb6046..890cadebd 100644 --- a/Sources/Runestone/TextView/LineController/LineFragment.swift +++ b/Sources/Runestone/TextView/LineController/LineFragment.swift @@ -11,7 +11,7 @@ struct LineFragmentID: Identifiable, Hashable { extension LineFragmentID: CustomDebugStringConvertible { var debugDescription: String { - return id + id } } @@ -39,6 +39,6 @@ final class LineFragment { extension LineFragment: CustomDebugStringConvertible { var debugDescription: String { - return "[LineFragment id=\(id) descent=\(descent) baseSize=\(baseSize) scaledSize=\(scaledSize) yPosition=\(yPosition)]" + "[LineFragment id=\(id) descent=\(descent) baseSize=\(baseSize) scaledSize=\(scaledSize) yPosition=\(yPosition)]" } } diff --git a/Sources/Runestone/TextView/LineController/LineFragmentCharacterLocationQuery.swift b/Sources/Runestone/TextView/LineController/LineFragmentCharacterLocationQuery.swift index 6d88af580..66ede15d3 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentCharacterLocationQuery.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentCharacterLocationQuery.swift @@ -13,11 +13,11 @@ final class LineFragmentCharacterLocationQuery: RedBlackTreeSearchQuery { } func shouldTraverseLeftChildren(of node: RedBlackTreeNode) -> Bool { - return node.nodeTotalValue >= range.lowerBound + node.nodeTotalValue >= range.lowerBound } func shouldTraverseRightChildren(of node: RedBlackTreeNode) -> Bool { - return node.nodeTotalValue <= range.upperBound + node.nodeTotalValue <= range.upperBound } func shouldInclude(_ node: RedBlackTreeNode) -> Bool { diff --git a/Sources/Runestone/TextView/LineController/LineFragmentController.swift b/Sources/Runestone/TextView/LineController/LineFragmentController.swift index 4aadbe5cd..e56645e51 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentController.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentController.swift @@ -23,7 +23,7 @@ final class LineFragmentController { } var markedRange: NSRange? { get { - return renderer.markedRange + renderer.markedRange } set { if newValue != renderer.markedRange { @@ -34,7 +34,7 @@ final class LineFragmentController { } var markedTextBackgroundColor: MultiPlatformColor { get { - return renderer.markedTextBackgroundColor + renderer.markedTextBackgroundColor } set { if newValue != renderer.markedTextBackgroundColor { @@ -45,7 +45,7 @@ final class LineFragmentController { } var markedTextBackgroundCornerRadius: CGFloat { get { - return renderer.markedTextBackgroundCornerRadius + renderer.markedTextBackgroundCornerRadius } set { if newValue != renderer.markedTextBackgroundCornerRadius { @@ -56,7 +56,7 @@ final class LineFragmentController { } var highlightedRangeFragments: [HighlightedRangeFragment] { get { - return renderer.highlightedRangeFragments + renderer.highlightedRangeFragments } set { if newValue != renderer.highlightedRangeFragments { @@ -78,6 +78,6 @@ final class LineFragmentController { // MARK: - LineFragmentRendererDelegate extension LineFragmentController: LineFragmentRendererDelegate { func string(in lineFragmentRenderer: LineFragmentRenderer) -> String? { - return delegate?.string(in: self) + delegate?.string(in: self) } } diff --git a/Sources/Runestone/TextView/LineController/LineFragmentFrameQuery.swift b/Sources/Runestone/TextView/LineController/LineFragmentFrameQuery.swift index d8690d79e..f97d4549c 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentFrameQuery.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentFrameQuery.swift @@ -12,11 +12,11 @@ final class LineFragmentFrameQuery: RedBlackTreeSearchQuery { } func shouldTraverseLeftChildren(of node: RedBlackTreeNode) -> Bool { - return node.data.totalLineFragmentHeight >= range.lowerBound + node.data.totalLineFragmentHeight >= range.lowerBound } func shouldTraverseRightChildren(of node: RedBlackTreeNode) -> Bool { - return node.data.totalLineFragmentHeight <= range.upperBound + node.data.totalLineFragmentHeight <= range.upperBound } func shouldInclude(_ node: RedBlackTreeNode) -> Bool { diff --git a/Sources/Runestone/TextView/LineController/LineFragmentNode.swift b/Sources/Runestone/TextView/LineController/LineFragmentNode.swift index bd6aefde2..76bb81244 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentNode.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentNode.swift @@ -8,7 +8,7 @@ struct LineFragmentNodeID: RedBlackTreeNodeID { final class LineFragmentNodeData { var lineFragment: LineFragment? var lineFragmentHeight: CGFloat { - return lineFragment?.scaledSize.height ?? 0 + lineFragment?.scaledSize.height ?? 0 } var totalLineFragmentHeight: CGFloat = 0 diff --git a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift index 66cf534e0..495845776 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift @@ -20,7 +20,7 @@ final class LineFragmentRenderer { var highlightedRangeFragments: [HighlightedRangeFragment] = [] private var showInvisibleCharacters: Bool { - return invisibleCharacterConfiguration.showTabs + invisibleCharacterConfiguration.showTabs || invisibleCharacterConfiguration.showSpaces || invisibleCharacterConfiguration.showLineBreaks || invisibleCharacterConfiguration.showSoftLineBreaks @@ -162,6 +162,6 @@ private extension LineFragmentRenderer { } private func isLineBreak(_ string: String.Element) -> Bool { - return string == Symbol.Character.lineFeed || string == Symbol.Character.carriageReturn || string == Symbol.Character.carriageReturnLineFeed + string == Symbol.Character.lineFeed || string == Symbol.Character.carriageReturn || string == Symbol.Character.carriageReturnLineFeed } } diff --git a/Sources/Runestone/TextView/LineController/LineTypesetter.swift b/Sources/Runestone/TextView/LineController/LineTypesetter.swift index 38f12f9ce..f0af63938 100644 --- a/Sources/Runestone/TextView/LineController/LineTypesetter.swift +++ b/Sources/Runestone/TextView/LineController/LineTypesetter.swift @@ -45,10 +45,10 @@ final class LineTypesetter { } } var isFinishedTypesetting: Bool { - return startOffset >= stringLength + startOffset >= stringLength } var typesetLength: Int { - return startOffset + startOffset } private let lineID: String diff --git a/Sources/Runestone/TextView/PageGuide/PageGuideView.swift b/Sources/Runestone/TextView/PageGuide/PageGuideView.swift index 019a84bd2..b43e46bfb 100644 --- a/Sources/Runestone/TextView/PageGuide/PageGuideView.swift +++ b/Sources/Runestone/TextView/PageGuide/PageGuideView.swift @@ -15,7 +15,7 @@ final class PageGuideView: MultiPlatformView { } var hairlineColor: MultiPlatformColor? { get { - return hairlineView.backgroundColor + hairlineView.backgroundColor } set { hairlineView.backgroundColor = newValue diff --git a/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift b/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift index 3856240e7..47ab89680 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift @@ -33,7 +33,7 @@ struct ParsedReplacementString: Equatable { let components: [Component] var containsPlaceholder: Bool { - return components.contains { component in + components.contains { component in switch component { case .text: return false diff --git a/Sources/Runestone/TextView/SearchAndReplace/SearchController.swift b/Sources/Runestone/TextView/SearchAndReplace/SearchController.swift index 9ae3310eb..f8679e86c 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/SearchController.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/SearchController.swift @@ -14,8 +14,8 @@ final class SearchController { } func search(for query: SearchQuery) -> [SearchResult] { - return search(for: query) { textCheckingResult in - return searchResult(in: textCheckingResult.range) + search(for: query) { textCheckingResult in + searchResult(in: textCheckingResult.range) } } @@ -34,8 +34,8 @@ final class SearchController { private extension SearchController { private func search(for query: SearchQuery, replacingWithPlainText replacementText: String) -> [SearchReplaceResult] { - return search(for: query) { textCheckingResult in - return searchReplaceResult(in: textCheckingResult.range, replacementText: replacementText) + search(for: query) { textCheckingResult in + searchReplaceResult(in: textCheckingResult.range, replacementText: replacementText) } } diff --git a/Sources/Runestone/TextView/SearchAndReplace/SearchQuery.swift b/Sources/Runestone/TextView/SearchAndReplace/SearchQuery.swift index 86231e164..250f7fc5f 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/SearchQuery.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/SearchQuery.swift @@ -46,7 +46,7 @@ public struct SearchQuery: Hashable, Equatable { } } private var escapedText: String { - return NSRegularExpression.escapedPattern(for: text) + NSRegularExpression.escapedPattern(for: text) } private var regularExpressionOptions: NSRegularExpression.Options { var options: NSRegularExpression.Options = [.anchorsMatchLines] diff --git a/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift b/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift index 6e77294fa..51e60c8b6 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift @@ -19,7 +19,7 @@ enum StringModifier { } } var string: String { - return "\\" + String(character) + "\\" + String(character) } private var terminatesStringModification: Bool { diff --git a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift index 6f4d02c56..b14205fb4 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift @@ -54,11 +54,11 @@ final class UITextSearchingHelper: NSObject { @available(iOS 16, *) extension UITextSearchingHelper: UITextSearching { var supportsTextReplacement: Bool { - return true + true } var selectedTextRange: UITextRange? { - return _textView.selectedTextRange + _textView.selectedTextRange } func compare(_ foundRange: UITextRange, toRange: UITextRange, document: AnyHashable??) -> ComparisonResult { @@ -169,7 +169,7 @@ private extension UITextSearchingHelper { extension UITextSearchingHelper: UIFindInteractionDelegate { @available(iOS 16, *) func findInteraction(_ interaction: UIFindInteraction, sessionFor view: UIView) -> UIFindSession? { - return UITextSearchingFindSession(searchableObject: self) + UITextSearchingFindSession(searchableObject: self) } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextInternalLanguageMode.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextInternalLanguageMode.swift index 302db1843..efa060740 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextInternalLanguageMode.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextInternalLanguageMode.swift @@ -8,37 +8,37 @@ final class PlainTextInternalLanguageMode: InternalLanguageMode { } func textDidChange(_ change: TextChange) -> LineChangeSet { - return LineChangeSet() + LineChangeSet() } func tokenType(at location: Int) -> String? { - return nil + nil } func createLineSyntaxHighlighter() -> LineSyntaxHighlighter { - return PlainTextSyntaxHighlighter() + PlainTextSyntaxHighlighter() } func highestSyntaxNode(at linePosition: LinePosition) -> SyntaxNode? { - return nil + nil } func syntaxNode(at linePosition: LinePosition) -> SyntaxNode? { - return nil + nil } func currentIndentLevel(of line: DocumentLineNode, using indentStrategy: IndentStrategy) -> Int { - return 0 + 0 } func strategyForInsertingLineBreak( from startLinePosition: LinePosition, to endLinePosition: LinePosition, using indentStrategy: IndentStrategy) -> InsertLineBreakIndentStrategy { - return InsertLineBreakIndentStrategy(indentLevel: 0, insertExtraLineBreak: false) + InsertLineBreakIndentStrategy(indentLevel: 0, insertExtraLineBreak: false) } func detectIndentStrategy() -> DetectedIndentStrategy { - return .unknown + .unknown } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextSyntaxHighlighter.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextSyntaxHighlighter.swift index 36231f7d4..e77967289 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextSyntaxHighlighter.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextSyntaxHighlighter.swift @@ -5,13 +5,13 @@ final class PlainTextSyntaxHighlighter: LineSyntaxHighlighter { var theme: Theme = DefaultTheme() var kern: CGFloat = 0 var canHighlight: Bool { - return false + false } func syntaxHighlight(_ input: LineSyntaxHighlighterInput) {} func syntaxHighlight(_ input: LineSyntaxHighlighterInput, completion: @escaping AsyncCallback) { - return completion(.success(())) + completion(.success(())) } func cancel() {} diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift index 39bf89a8a..33aaa4a96 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift @@ -8,7 +8,7 @@ protocol TreeSitterLanguageModeDelegate: AnyObject { final class TreeSitterInternalLanguageMode: InternalLanguageMode { weak var delegate: TreeSitterLanguageModeDelegate? var canHighlight: Bool { - return rootLanguageLayer.canHighlight + rootLanguageLayer.canHighlight } private let stringView: StringView @@ -72,11 +72,11 @@ final class TreeSitterInternalLanguageMode: InternalLanguageMode { } func captures(in range: ByteRange) -> [TreeSitterCapture] { - return rootLanguageLayer.captures(in: range) + rootLanguageLayer.captures(in: range) } func createLineSyntaxHighlighter() -> LineSyntaxHighlighter { - return TreeSitterSyntaxHighlighter(stringView: stringView, languageMode: self, operationQueue: operationQueue) + TreeSitterSyntaxHighlighter(stringView: stringView, languageMode: self, operationQueue: operationQueue) } func currentIndentLevel(of line: DocumentLineNode, using indentStrategy: IndentStrategy) -> Int { @@ -129,6 +129,6 @@ final class TreeSitterInternalLanguageMode: InternalLanguageMode { extension TreeSitterInternalLanguageMode: TreeSitterParserDelegate { func parser(_ parser: TreeSitterParser, bytesAt byteIndex: ByteCount) -> TreeSitterTextProviderResult? { - return delegate?.treeSitterLanguageMode(self, bytesAt: byteIndex) + delegate?.treeSitterLanguageMode(self, bytesAt: byteIndex) } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift index 9ac441769..3b84a5021 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift @@ -6,7 +6,7 @@ final class TreeSitterLanguageLayer { let language: TreeSitterInternalLanguage private(set) var tree: TreeSitterTree? var canHighlight: Bool { - return parser.language != nil && tree != nil + parser.language != nil && tree != nil } private let lineManager: LineManager @@ -253,7 +253,7 @@ extension TreeSitterLanguageLayer { extension TreeSitterLanguageLayer: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterLanguageLayer node=\(tree?.rootNode.debugDescription ?? "") childLanguageLayers=\(childLanguageLayers)]" + "[TreeSitterLanguageLayer node=\(tree?.rootNode.debugDescription ?? "") childLanguageLayers=\(childLanguageLayers)]" } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift index c5c4ff6f8..76a421b99 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift @@ -12,7 +12,7 @@ final class TreeSitterSyntaxHighlightToken { let font: MultiPlatformFont? let fontTraits: FontTraits var isEmpty: Bool { - return range.length == 0 || (textColor == nil && font == nil && shadow == nil) + range.length == 0 || (textColor == nil && font == nil && shadow == nil) } init(range: NSRange, textColor: MultiPlatformColor?, shadow: NSShadow?, font: MultiPlatformFont?, fontTraits: FontTraits) { @@ -26,7 +26,7 @@ final class TreeSitterSyntaxHighlightToken { extension TreeSitterSyntaxHighlightToken: Equatable { static func == (lhs: TreeSitterSyntaxHighlightToken, rhs: TreeSitterSyntaxHighlightToken) -> Bool { - return lhs.range == rhs.range && lhs.textColor == rhs.textColor && lhs.font == rhs.font + lhs.range == rhs.range && lhs.textColor == rhs.textColor && lhs.font == rhs.font } } @@ -42,6 +42,6 @@ extension TreeSitterSyntaxHighlightToken { extension TreeSitterSyntaxHighlightToken: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterSyntaxHighlightToken: \(range.location) - \(range.length)]" + "[TreeSitterSyntaxHighlightToken: \(range.location) - \(range.length)]" } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift index 8951522d3..acc93e72a 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift @@ -18,7 +18,7 @@ final class TreeSitterSyntaxHighlighter: LineSyntaxHighlighter { var theme: Theme = DefaultTheme() var kern: CGFloat = 0 var canHighlight: Bool { - return languageMode.canHighlight + languageMode.canHighlight } private let stringView: StringView diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/PlainText/PlainTextLanguageMode.swift b/Sources/Runestone/TextView/SyntaxHighlighting/PlainText/PlainTextLanguageMode.swift index 79049a7b8..099341eda 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/PlainText/PlainTextLanguageMode.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/PlainText/PlainTextLanguageMode.swift @@ -10,6 +10,6 @@ public final class PlainTextLanguageMode { extension PlainTextLanguageMode: LanguageMode { func makeInternalLanguageMode(stringView: StringView, lineManager: LineManager) -> InternalLanguageMode { - return PlainTextInternalLanguageMode() + PlainTextInternalLanguageMode() } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterIndentationScopes.swift b/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterIndentationScopes.swift index 8b5a7899b..161d61b47 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterIndentationScopes.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterIndentationScopes.swift @@ -61,7 +61,7 @@ public final class TreeSitterIndentationScopes { extension TreeSitterIndentationScopes: CustomDebugStringConvertible { public var debugDescription: String { - return "[TreeSitterIndentationScopes indent=\(indent)" + "[TreeSitterIndentationScopes indent=\(indent)" + " inheritIndent=\(inheritIndent)" + " outdent=\(outdent)" + " whitespaceDenotesBlocks=\(whitespaceDenotesBlocks)]" diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterLanguageMode.swift b/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterLanguageMode.swift index 328ae1615..546509005 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterLanguageMode.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterLanguageMode.swift @@ -23,7 +23,7 @@ public final class TreeSitterLanguageMode { extension TreeSitterLanguageMode: LanguageMode { func makeInternalLanguageMode(stringView: StringView, lineManager: LineManager) -> InternalLanguageMode { - return TreeSitterInternalLanguageMode( + TreeSitterInternalLanguageMode( language: language.internalLanguage, languageProvider: languageProvider, stringView: stringView, diff --git a/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift b/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift index f163ff009..a35229f2f 100644 --- a/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift +++ b/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift @@ -35,8 +35,8 @@ final class SelectionRectFactory { let leadingLineSpacing = gutterWidthService.gutterWidth + textContainerInset.left let selectsLineEnding = range.upperBound == endLine.location let adjustedRange = NSRange(location: range.location, length: selectsLineEnding ? range.length - 1 : range.length) - let startCaretRect = caretRectFactory.caretRect(at: adjustedRange.lowerBound,allowMovingCaretToNextLineFragment: true) - let endCaretRect = caretRectFactory.caretRect(at: adjustedRange.upperBound,allowMovingCaretToNextLineFragment: false) + let startCaretRect = caretRectFactory.caretRect(at: adjustedRange.lowerBound, allowMovingCaretToNextLineFragment: true) + let endCaretRect = caretRectFactory.caretRect(at: adjustedRange.upperBound, allowMovingCaretToNextLineFragment: false) let fullWidth = max(contentSizeService.contentWidth, contentSizeService.scrollViewSize.width) - leadingLineSpacing - textContainerInset.right if startCaretRect.minY == endCaretRect.minY && startCaretRect.maxY == endCaretRect.maxY { // Selecting text in the same line fragment. diff --git a/Sources/Runestone/TextView/TextSelection/iOS/TextSelectionRect.swift b/Sources/Runestone/TextView/TextSelection/iOS/TextSelectionRect.swift index aac4aa623..3098794c6 100644 --- a/Sources/Runestone/TextView/TextSelection/iOS/TextSelectionRect.swift +++ b/Sources/Runestone/TextView/TextSelection/iOS/TextSelectionRect.swift @@ -9,19 +9,19 @@ import UIKit #if os(iOS) final class TextSelectionRect: UITextSelectionRect { override var rect: CGRect { - return _rect + _rect } override var writingDirection: NSWritingDirection { - return _writingDirection + _writingDirection } override var containsStart: Bool { - return _containsStart + _containsStart } override var containsEnd: Bool { - return _containsEnd + _containsEnd } override var isVertical: Bool { - return _isVertical + _isVertical } private let _rect: CGRect diff --git a/Sources/Runestone/TreeSitter/TreeSitterCapture.swift b/Sources/Runestone/TreeSitter/TreeSitterCapture.swift index f35cd89d6..14e05909d 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterCapture.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterCapture.swift @@ -27,6 +27,6 @@ final class TreeSitterCapture { extension TreeSitterCapture: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterCapture byteRange=\(byteRange) name=\(name) properties=\(properties) textPredicates=\(textPredicates)]" + "[TreeSitterCapture byteRange=\(byteRange) name=\(name) properties=\(properties) textPredicates=\(textPredicates)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterInputEdit.swift b/Sources/Runestone/TreeSitter/TreeSitterInputEdit.swift index e22117405..7dbedd5c5 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterInputEdit.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterInputEdit.swift @@ -25,7 +25,7 @@ final class TreeSitterInputEdit { extension TreeSitterInputEdit: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterInputEdit startByte=\(startByte) oldEndByte=\(oldEndByte) newEndByte=\(newEndByte)" + "[TreeSitterInputEdit startByte=\(startByte) oldEndByte=\(oldEndByte) newEndByte=\(newEndByte)" + " startPoint=\(startPoint) oldEndPoint=\(oldEndPoint) newEndPoint=\(newEndPoint)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterNode.swift b/Sources/Runestone/TreeSitter/TreeSitterNode.swift index ec84964f4..e7a89865b 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterNode.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterNode.swift @@ -19,34 +19,34 @@ final class TreeSitterNode { } } var startByte: ByteCount { - return ByteCount(ts_node_start_byte(rawValue)) + ByteCount(ts_node_start_byte(rawValue)) } var endByte: ByteCount { - return ByteCount(ts_node_end_byte(rawValue)) + ByteCount(ts_node_end_byte(rawValue)) } var startPoint: TreeSitterTextPoint { - return TreeSitterTextPoint(ts_node_start_point(rawValue)) + TreeSitterTextPoint(ts_node_start_point(rawValue)) } var endPoint: TreeSitterTextPoint { - return TreeSitterTextPoint(ts_node_end_point(rawValue)) + TreeSitterTextPoint(ts_node_end_point(rawValue)) } var byteRange: ByteRange { - return ByteRange(from: startByte, to: endByte) + ByteRange(from: startByte, to: endByte) } var parent: TreeSitterNode? { - return getRelationship(using: ts_node_parent) + getRelationship(using: ts_node_parent) } var previousSibling: TreeSitterNode? { - return getRelationship(using: ts_node_prev_sibling) + getRelationship(using: ts_node_prev_sibling) } var nextSibling: TreeSitterNode? { - return getRelationship(using: ts_node_next_sibling) + getRelationship(using: ts_node_next_sibling) } var textRange: TreeSitterTextRange { - return TreeSitterTextRange(startPoint: startPoint, endPoint: endPoint, startByte: startByte, endByte: endByte) + TreeSitterTextRange(startPoint: startPoint, endPoint: endPoint, startByte: startByte, endByte: endByte) } var childCount: Int { - return Int(ts_node_child_count(rawValue)) + Int(ts_node_child_count(rawValue)) } init(node: TSNode) { @@ -81,7 +81,7 @@ private extension TreeSitterNode { extension TreeSitterNode: Hashable { static func == (lhs: TreeSitterNode, rhs: TreeSitterNode) -> Bool { - return lhs.rawValue.id == rhs.rawValue.id + lhs.rawValue.id == rhs.rawValue.id } func hash(into hasher: inout Hasher) { @@ -91,6 +91,6 @@ extension TreeSitterNode: Hashable { extension TreeSitterNode: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterNode startByte=\(startByte) endByte=\(endByte) startPoint=\(startPoint) endPoint=\(endPoint)]" + "[TreeSitterNode startByte=\(startByte) endByte=\(endByte) startPoint=\(startPoint) endPoint=\(endPoint)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterParser.swift b/Sources/Runestone/TreeSitter/TreeSitterParser.swift index 9d6c62dcd..d7073445e 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterParser.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterParser.swift @@ -14,7 +14,7 @@ final class TreeSitterParser { } } var canParse: Bool { - return language != nil + language != nil } private var pointer: OpaquePointer @@ -67,7 +67,7 @@ final class TreeSitterParser { func setIncludedRanges(_ ranges: [TreeSitterTextRange]) -> Bool { let rawRanges = ranges.map { $0.rawValue } return rawRanges.withUnsafeBufferPointer { rangesPointer in - return ts_parser_set_included_ranges(pointer, rangesPointer.baseAddress, UInt32(rawRanges.count)) + ts_parser_set_included_ranges(pointer, rangesPointer.baseAddress, UInt32(rawRanges.count)) } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterPredicate.swift b/Sources/Runestone/TreeSitter/TreeSitterPredicate.swift index 973f28987..eebb8eee3 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterPredicate.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterPredicate.swift @@ -17,7 +17,7 @@ final class TreeSitterPredicate { extension TreeSitterPredicate: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterPredicate name=\(name) steps=\(steps)]" + "[TreeSitterPredicate name=\(name) steps=\(steps)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterQuery.swift b/Sources/Runestone/TreeSitter/TreeSitterQuery.swift index 3ac34caf8..1619d2575 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterQuery.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterQuery.swift @@ -14,7 +14,7 @@ final class TreeSitterQuery { private let language: UnsafePointer private var patternCount: UInt32 { - return ts_query_pattern_count(pointer) + ts_query_pattern_count(pointer) } init(source: String, language: UnsafePointer) throws { diff --git a/Sources/Runestone/TreeSitter/TreeSitterQueryCursor.swift b/Sources/Runestone/TreeSitter/TreeSitterQueryCursor.swift index ce29f5f84..8c1c51201 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterQueryCursor.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterQueryCursor.swift @@ -47,7 +47,7 @@ final class TreeSitterQueryCursor { let match = TreeSitterQueryMatch(captures: captures) let evaluator = TreeSitterTextPredicatesEvaluator(match: match, stringView: stringView) result += captures.filter { capture in - return capture.byteRange.length > 0 && evaluator.evaluatePredicates(in: capture) + capture.byteRange.length > 0 && evaluator.evaluatePredicates(in: capture) } } return result diff --git a/Sources/Runestone/TreeSitter/TreeSitterQueryMatch.swift b/Sources/Runestone/TreeSitter/TreeSitterQueryMatch.swift index 1dcd651bf..37700a5f2 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterQueryMatch.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterQueryMatch.swift @@ -8,12 +8,12 @@ final class TreeSitterQueryMatch { } func capture(forIndex index: UInt32) -> TreeSitterCapture? { - return captures.first { $0.index == index } + captures.first { $0.index == index } } } extension TreeSitterQueryMatch: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterQueryMatch captures=\(captures.count)]" + "[TreeSitterQueryMatch captures=\(captures.count)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterTextPoint.swift b/Sources/Runestone/TreeSitter/TreeSitterTextPoint.swift index 83fa9fdf2..872069254 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterTextPoint.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterTextPoint.swift @@ -2,10 +2,10 @@ import TreeSitter final class TreeSitterTextPoint { var row: UInt32 { - return rawValue.row + rawValue.row } var column: UInt32 { - return rawValue.column + rawValue.column } let rawValue: TSPoint @@ -21,6 +21,6 @@ final class TreeSitterTextPoint { extension TreeSitterTextPoint: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTextPoint row=\(row) column=\(column)]" + "[TreeSitterTextPoint row=\(row) column=\(column)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterTextPredicate.swift b/Sources/Runestone/TreeSitter/TreeSitterTextPredicate.swift index 6801be02c..2ecbaddb9 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterTextPredicate.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterTextPredicate.swift @@ -53,13 +53,13 @@ enum TreeSitterTextPredicate { extension TreeSitterTextPredicate.CaptureEqualsStringParameters: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTextPredicate.CaptureEqualsStringParameters captureIndex=\(captureIndex) string=\(string) isPositive=\(isPositive)]" + "[TreeSitterTextPredicate.CaptureEqualsStringParameters captureIndex=\(captureIndex) string=\(string) isPositive=\(isPositive)]" } } extension TreeSitterTextPredicate.CaptureEqualsCaptureParameters: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTextPredicate.CaptureEqualsCaptureParameters lhsCaptureIndex=\(lhsCaptureIndex)" + "[TreeSitterTextPredicate.CaptureEqualsCaptureParameters lhsCaptureIndex=\(lhsCaptureIndex)" + " rhsCaptureIndex=\(rhsCaptureIndex)" + " isPositive=\(isPositive)]" } @@ -67,6 +67,6 @@ extension TreeSitterTextPredicate.CaptureEqualsCaptureParameters: CustomDebugStr extension TreeSitterTextPredicate.CaptureMatchesPatternParameters: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTextPredicate.CaptureMatchesPatternParameters captureIndex=\(captureIndex) pattern=\(pattern) isPositive=\(isPositive)]" + "[TreeSitterTextPredicate.CaptureMatchesPatternParameters captureIndex=\(captureIndex) pattern=\(pattern) isPositive=\(isPositive)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterTextRange.swift b/Sources/Runestone/TreeSitter/TreeSitterTextRange.swift index df952dcc1..ade82fc83 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterTextRange.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterTextRange.swift @@ -3,16 +3,16 @@ import TreeSitter final class TreeSitterTextRange { let rawValue: TSRange var startPoint: TreeSitterTextPoint { - return TreeSitterTextPoint(row: rawValue.start_point.row, column: rawValue.start_point.column) + TreeSitterTextPoint(row: rawValue.start_point.row, column: rawValue.start_point.column) } var endPoint: TreeSitterTextPoint { - return TreeSitterTextPoint(row: rawValue.end_point.row, column: rawValue.end_point.column) + TreeSitterTextPoint(row: rawValue.end_point.row, column: rawValue.end_point.column) } var startByte: ByteCount { - return ByteCount(rawValue.start_byte) + ByteCount(rawValue.start_byte) } var endByte: ByteCount { - return ByteCount(rawValue.end_byte) + ByteCount(rawValue.end_byte) } init(startPoint: TreeSitterTextPoint, endPoint: TreeSitterTextPoint, startByte: ByteCount, endByte: ByteCount) { @@ -26,6 +26,6 @@ final class TreeSitterTextRange { extension TreeSitterTextRange: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTextRange startByte=\(startByte) endByte=\(endByte) startPoint=\(startPoint) endPoint=\(endPoint)]" + "[TreeSitterTextRange startByte=\(startByte) endByte=\(endByte) startPoint=\(startPoint) endPoint=\(endPoint)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterTree.swift b/Sources/Runestone/TreeSitter/TreeSitterTree.swift index dd6cfaa57..8de366f16 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterTree.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterTree.swift @@ -3,7 +3,7 @@ import TreeSitter final class TreeSitterTree { let pointer: OpaquePointer var rootNode: TreeSitterNode { - return TreeSitterNode(node: ts_tree_root_node(pointer)) + TreeSitterNode(node: ts_tree_root_node(pointer)) } init(_ tree: OpaquePointer) { @@ -35,6 +35,6 @@ final class TreeSitterTree { extension TreeSitterTree: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTree rootNode=\(rootNode)]" + "[TreeSitterTree rootNode=\(rootNode)]" } } diff --git a/Tests/RunestoneTests/Mock/MockTreeSitterParserDelegate.swift b/Tests/RunestoneTests/Mock/MockTreeSitterParserDelegate.swift index 248c9f3f5..6edde3808 100644 --- a/Tests/RunestoneTests/Mock/MockTreeSitterParserDelegate.swift +++ b/Tests/RunestoneTests/Mock/MockTreeSitterParserDelegate.swift @@ -4,7 +4,7 @@ import Foundation final class MockTreeSitterParserDelegate: TreeSitterParserDelegate { var string: NSString { get { - return stringView.string + stringView.string } set { stringView.string = newValue diff --git a/Tests/RunestoneTests/XCTestManifests.swift b/Tests/RunestoneTests/XCTestManifests.swift index b8387f40c..b3cf78dba 100644 --- a/Tests/RunestoneTests/XCTestManifests.swift +++ b/Tests/RunestoneTests/XCTestManifests.swift @@ -2,7 +2,7 @@ import XCTest #if !canImport(ObjectiveC) public func allTests() -> [XCTestCaseEntry] { - return [ + [ testCase(RunestoneTests.allTests) ] } diff --git a/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift b/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift index ce7dc6356..e49db4a64 100644 --- a/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift +++ b/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift @@ -302,7 +302,7 @@ extension TextInputStringTokenizerTests: LineControllerStorageDelegate { extension TextInputStringTokenizerTests: LineControllerDelegate { func lineSyntaxHighlighter(for lineController: LineController) -> LineSyntaxHighlighter? { - return PlainTextSyntaxHighlighter() + PlainTextSyntaxHighlighter() } func lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ lineController: LineController) {} diff --git a/UITests/Host/Sources/AppDelegate.swift b/UITests/Host/Sources/AppDelegate.swift index 6c8b84f49..34afbfc18 100644 --- a/UITests/Host/Sources/AppDelegate.swift +++ b/UITests/Host/Sources/AppDelegate.swift @@ -4,6 +4,6 @@ import UIKit final class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - return true + true } } diff --git a/UITests/Host/Sources/ProcessInfo+Helpers.swift b/UITests/Host/Sources/ProcessInfo+Helpers.swift index 766f636cb..400b0fbeb 100644 --- a/UITests/Host/Sources/ProcessInfo+Helpers.swift +++ b/UITests/Host/Sources/ProcessInfo+Helpers.swift @@ -2,6 +2,6 @@ import Foundation extension ProcessInfo { var useCRLFLineEndings: Bool { - return environment["crlfLineEndings"] != nil + environment["crlfLineEndings"] != nil } } diff --git a/UITests/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift b/UITests/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift index 988a5730b..c2881dde2 100644 --- a/UITests/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift +++ b/UITests/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift @@ -2,7 +2,7 @@ import Runestone public extension TreeSitterIndentationScopes { static var javaScript: TreeSitterIndentationScopes { - return TreeSitterIndentationScopes( + TreeSitterIndentationScopes( indent: [ "array", "object", diff --git a/UITests/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift b/UITests/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift index 8142ac143..f757cf95a 100644 --- a/UITests/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift +++ b/UITests/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift @@ -17,6 +17,6 @@ public extension TreeSitterLanguage { private extension TreeSitterLanguage { static func queryFileURL(forQueryNamed queryName: String) -> URL { - return Bundle.module.url(forResource: "queries/" + queryName, withExtension: "scm")! + Bundle.module.url(forResource: "queries/" + queryName, withExtension: "scm")! } } From 9ac06b7b407de26651ae622d5a00f11058d91b2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 07:57:30 +0100 Subject: [PATCH 121/232] Adds implicit_return SwiftLint rule --- .swiftlint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.swiftlint.yml b/.swiftlint.yml index b0d1e1294..098f58135 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -30,6 +30,7 @@ opt_in_rules: - first_where - flatmap_over_map_reduce - identical_operands + - implicit_return - implicitly_unwrapped_optional - joined_default_parameter - last_where From 44a0f5166d71d7c894bd7df78360b980413bf03d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 07:57:39 +0100 Subject: [PATCH 122/232] Fixes SwiftLint rules --- Example/Example/Library/CodeSample.swift | 2 +- .../Example/Library/ProcessInfo+Helpers.swift | 4 +- .../Library/UserDefaults+Helpers.swift | 16 +- Example/Example/Main/MainViewController.swift | 4 +- .../IndentationScopes.swift | 2 +- .../TreeSitterLanguage.swift | 2 +- .../PlainTextTheme.swift | 2 +- Sources/Runestone/Library/ByteCount.swift | 24 +-- Sources/Runestone/Library/ByteRange.swift | 10 +- Sources/Runestone/Library/Caret.swift | 2 +- .../Runestone/Library/NSString+Helpers.swift | 4 +- .../Runestone/Library/String+Helpers.swift | 2 +- .../Runestone/Library/UIFont+Helpers.swift | 2 +- .../Library/UIScrollView+Helpers.swift | 2 +- .../LineManager/DocumentLineNodeData.swift | 12 +- .../Runestone/LineManager/LineChangeSet.swift | 2 +- .../Runestone/LineManager/LineManager.swift | 32 ++-- .../Runestone/LineManager/LinePosition.swift | 4 +- .../ClosedRangeValueDescriptor.swift | 4 +- .../Runestone/RedBlackTree/RedBlackTree.swift | 12 +- .../RedBlackTreeChildrenUpdater.swift | 2 +- .../RedBlackTree/RedBlackTreeNode.swift | 8 +- .../Runestone/TextView/Appearance/Theme.swift | 12 +- .../TextView/Core/ContentSizeService.swift | 4 +- .../TextView/Core/EditMenuController.swift | 8 +- .../TextView/Core/IndexedRange.swift | 6 +- .../Runestone/TextView/Core/StringView.swift | 2 +- .../Core/TextInputStringTokenizer.swift | 10 +- .../TextView/Core/TextInputView.swift | 88 +++++----- .../Runestone/TextView/Core/TextView.swift | 154 +++++++++--------- .../TextView/Core/TextViewDelegate.swift | 12 +- .../TextView/Core/TimedUndoManager.swift | 2 +- .../Gutter/GutterBackgroundView.swift | 2 +- .../TextView/Gutter/LineNumberView.swift | 6 +- .../TextView/Highlight/HighlightedRange.swift | 4 +- .../Highlight/HighlightedRangeFragment.swift | 2 +- .../LineController/LineController.swift | 14 +- .../LineControllerFactory.swift | 8 +- .../LineControllerStorage.swift | 4 +- .../LineController/LineFragment.swift | 4 +- .../LineFragmentCharacterLocationQuery.swift | 4 +- .../LineFragmentController.swift | 10 +- .../LineFragmentFrameQuery.swift | 4 +- .../LineController/LineFragmentNode.swift | 2 +- .../LineController/LineFragmentRenderer.swift | 4 +- .../LineController/LineTypesetter.swift | 4 +- .../TextView/PageGuide/PageGuideView.swift | 2 +- .../ParsedReplacementString.swift | 2 +- .../SearchAndReplace/SearchController.swift | 8 +- .../SearchAndReplace/SearchQuery.swift | 2 +- .../SearchAndReplace/StringModifier.swift | 2 +- .../UITextSearchingHelper.swift | 8 +- .../PlainTextInternalLanguageMode.swift | 16 +- .../PlainTextSyntaxHighlighter.swift | 4 +- .../TreeSitterInternalLanguageMode.swift | 8 +- .../TreeSitter/TreeSitterLanguageLayer.swift | 4 +- .../TreeSitterSyntaxHighlightToken.swift | 6 +- .../TreeSitterSyntaxHighlighter.swift | 2 +- .../PlainText/PlainTextLanguageMode.swift | 2 +- .../TreeSitterIndentationScopes.swift | 2 +- .../TreeSitter/TreeSitterLanguageMode.swift | 2 +- .../TextSelection/TextSelectionRect.swift | 10 +- .../TreeSitter/TreeSitterCapture.swift | 2 +- .../TreeSitter/TreeSitterInputEdit.swift | 2 +- .../Runestone/TreeSitter/TreeSitterNode.swift | 24 +-- .../TreeSitter/TreeSitterParser.swift | 4 +- .../TreeSitter/TreeSitterPredicate.swift | 2 +- .../TreeSitter/TreeSitterQuery.swift | 2 +- .../TreeSitter/TreeSitterQueryCursor.swift | 2 +- .../TreeSitter/TreeSitterQueryMatch.swift | 4 +- .../TreeSitter/TreeSitterTextPoint.swift | 6 +- .../TreeSitter/TreeSitterTextPredicate.swift | 6 +- .../TreeSitter/TreeSitterTextRange.swift | 10 +- .../Runestone/TreeSitter/TreeSitterTree.swift | 4 +- .../Mock/MockTreeSitterParserDelegate.swift | 2 +- .../TextInputStringTokenizerTests.swift | 2 +- Tests/RunestoneTests/XCTestManifests.swift | 2 +- UITests/Host/Sources/AppDelegate.swift | 2 +- .../Host/Sources/ProcessInfo+Helpers.swift | 2 +- .../HostUITests/XCUIApplication+Helpers.swift | 2 +- .../IndentationScopes.swift | 2 +- .../TreeSitterLanguage.swift | 2 +- 82 files changed, 344 insertions(+), 344 deletions(-) diff --git a/Example/Example/Library/CodeSample.swift b/Example/Example/Library/CodeSample.swift index d516779c6..2150d6c7d 100644 --- a/Example/Example/Library/CodeSample.swift +++ b/Example/Example/Library/CodeSample.swift @@ -2,7 +2,7 @@ import Foundation enum CodeSample { static var `default`: String { - return """ + """ /** * This is a Runestone text view with syntax highlighting * for the JavaScript programming language. diff --git a/Example/Example/Library/ProcessInfo+Helpers.swift b/Example/Example/Library/ProcessInfo+Helpers.swift index 5e19736cc..efc364d3f 100644 --- a/Example/Example/Library/ProcessInfo+Helpers.swift +++ b/Example/Example/Library/ProcessInfo+Helpers.swift @@ -2,10 +2,10 @@ import Foundation extension ProcessInfo { var disableTextPersistance: Bool { - return environment["disableTextPersistance"] != nil + environment["disableTextPersistance"] != nil } var useCRLFLineEndings: Bool { - return environment["crlfLineEndings"] != nil + environment["crlfLineEndings"] != nil } } diff --git a/Example/Example/Library/UserDefaults+Helpers.swift b/Example/Example/Library/UserDefaults+Helpers.swift index 2bdae7aee..67d65d23d 100644 --- a/Example/Example/Library/UserDefaults+Helpers.swift +++ b/Example/Example/Library/UserDefaults+Helpers.swift @@ -15,7 +15,7 @@ extension UserDefaults { var text: String? { get { - return string(forKey: Key.text) + string(forKey: Key.text) } set { set(newValue, forKey: Key.text) @@ -23,7 +23,7 @@ extension UserDefaults { } var showLineNumbers: Bool { get { - return bool(forKey: Key.showLineNumbers) + bool(forKey: Key.showLineNumbers) } set { set(newValue, forKey: Key.showLineNumbers) @@ -31,7 +31,7 @@ extension UserDefaults { } var showInvisibleCharacters: Bool { get { - return bool(forKey: Key.showInvisibleCharacters) + bool(forKey: Key.showInvisibleCharacters) } set { set(newValue, forKey: Key.showInvisibleCharacters) @@ -39,7 +39,7 @@ extension UserDefaults { } var wrapLines: Bool { get { - return bool(forKey: Key.wrapLines) + bool(forKey: Key.wrapLines) } set { set(newValue, forKey: Key.wrapLines) @@ -47,7 +47,7 @@ extension UserDefaults { } var highlightSelectedLine: Bool { get { - return bool(forKey: Key.highlightSelectedLine) + bool(forKey: Key.highlightSelectedLine) } set { set(newValue, forKey: Key.highlightSelectedLine) @@ -55,7 +55,7 @@ extension UserDefaults { } var showPageGuide: Bool { get { - return bool(forKey: Key.showPageGuide) + bool(forKey: Key.showPageGuide) } set { set(newValue, forKey: Key.showPageGuide) @@ -76,7 +76,7 @@ extension UserDefaults { var isEditable: Bool { get { - return bool(forKey: Key.isEditable) + bool(forKey: Key.isEditable) } set { set(newValue, forKey: Key.isEditable) @@ -85,7 +85,7 @@ extension UserDefaults { var isSelectable: Bool { get { - return bool(forKey: Key.isSelectable) + bool(forKey: Key.isSelectable) } set { set(newValue, forKey: Key.isSelectable) diff --git a/Example/Example/Main/MainViewController.swift b/Example/Example/Main/MainViewController.swift index aeeeb8b35..2bf42e950 100644 --- a/Example/Example/Main/MainViewController.swift +++ b/Example/Example/Main/MainViewController.swift @@ -157,7 +157,7 @@ private extension MainViewController { } private func makeThemeMenuElements() -> [UIMenuElement] { - return [ + [ UIAction(title: "Theme") { [weak self] _ in self?.presentThemePicker() } @@ -215,7 +215,7 @@ extension MainViewController: TextViewDelegate { } func textView(_ textView: TextView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - return true + true } } diff --git a/Example/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift b/Example/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift index 988a5730b..c2881dde2 100644 --- a/Example/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift +++ b/Example/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift @@ -2,7 +2,7 @@ import Runestone public extension TreeSitterIndentationScopes { static var javaScript: TreeSitterIndentationScopes { - return TreeSitterIndentationScopes( + TreeSitterIndentationScopes( indent: [ "array", "object", diff --git a/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift b/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift index 8142ac143..f757cf95a 100644 --- a/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift +++ b/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift @@ -17,6 +17,6 @@ public extension TreeSitterLanguage { private extension TreeSitterLanguage { static func queryFileURL(forQueryNamed queryName: String) -> URL { - return Bundle.module.url(forResource: "queries/" + queryName, withExtension: "scm")! + Bundle.module.url(forResource: "queries/" + queryName, withExtension: "scm")! } } diff --git a/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift b/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift index 214af4002..564a805fa 100644 --- a/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift +++ b/Example/Themes/Sources/RunestonePlainTextTheme/PlainTextTheme.swift @@ -30,7 +30,7 @@ public final class PlainTextTheme: EditorTheme { public init() {} public func textColor(for rawHighlightName: String) -> UIColor? { - return nil + nil } public func fontTraits(for rawHighlightName: String) -> FontTraits { diff --git a/Sources/Runestone/Library/ByteCount.swift b/Sources/Runestone/Library/ByteCount.swift index 1666efd9f..cb512b594 100644 --- a/Sources/Runestone/Library/ByteCount.swift +++ b/Sources/Runestone/Library/ByteCount.swift @@ -3,7 +3,7 @@ import Foundation struct ByteCount: Hashable { private(set) var value: Int var utf16Length: Int { - return value / 2 + value / 2 } init(_ value: Int) { @@ -21,19 +21,19 @@ struct ByteCount: Hashable { extension ByteCount: Comparable { static func < (lhs: ByteCount, rhs: ByteCount) -> Bool { - return lhs.value < rhs.value + lhs.value < rhs.value } static func <= (lhs: ByteCount, rhs: ByteCount) -> Bool { - return lhs.value <= rhs.value + lhs.value <= rhs.value } static func >= (lhs: ByteCount, rhs: ByteCount) -> Bool { - return lhs.value >= rhs.value + lhs.value >= rhs.value } static func > (lhs: ByteCount, rhs: ByteCount) -> Bool { - return lhs.value > rhs.value + lhs.value > rhs.value } } @@ -42,11 +42,11 @@ extension ByteCount: Numeric { typealias IntegerLiteralType = Int static var zero: ByteCount { - return ByteCount(0) + ByteCount(0) } var magnitude: Int { - return value + value } init?(exactly source: T) where T: BinaryInteger { @@ -58,7 +58,7 @@ extension ByteCount: Numeric { } static func - (lhs: ByteCount, rhs: ByteCount) -> ByteCount { - return ByteCount(lhs.value - rhs.value) + ByteCount(lhs.value - rhs.value) } static func -= (lhs: inout ByteCount, rhs: ByteCount) { @@ -66,7 +66,7 @@ extension ByteCount: Numeric { } static func + (lhs: ByteCount, rhs: ByteCount) -> ByteCount { - return ByteCount(lhs.value + rhs.value) + ByteCount(lhs.value + rhs.value) } static func += (lhs: inout ByteCount, rhs: ByteCount) { @@ -74,7 +74,7 @@ extension ByteCount: Numeric { } static func * (lhs: ByteCount, rhs: ByteCount) -> ByteCount { - return ByteCount(lhs.value * rhs.value) + ByteCount(lhs.value * rhs.value) } static func *= (lhs: inout ByteCount, rhs: ByteCount) { @@ -84,12 +84,12 @@ extension ByteCount: Numeric { extension ByteCount: CustomStringConvertible { var description: String { - return "\(value)" + "\(value)" } } extension ByteCount: CustomDebugStringConvertible { var debugDescription: String { - return "\(value)" + "\(value)" } } diff --git a/Sources/Runestone/Library/ByteRange.swift b/Sources/Runestone/Library/ByteRange.swift index c48105671..88a953311 100644 --- a/Sources/Runestone/Library/ByteRange.swift +++ b/Sources/Runestone/Library/ByteRange.swift @@ -4,13 +4,13 @@ struct ByteRange: Hashable { let location: ByteCount let length: ByteCount var lowerBound: ByteCount { - return location + location } var upperBound: ByteCount { - return location + length + location + length } var isEmpty: Bool { - return length == 0 + length == 0 } init(location: ByteCount, length: ByteCount) { @@ -37,12 +37,12 @@ struct ByteRange: Hashable { extension ByteRange: CustomStringConvertible { var description: String { - return "{\(location), \(length)}" + "{\(location), \(length)}" } } extension ByteRange: CustomDebugStringConvertible { var debugDescription: String { - return "{\(location), \(length)}" + "{\(location), \(length)}" } } diff --git a/Sources/Runestone/Library/Caret.swift b/Sources/Runestone/Library/Caret.swift index 819d0c6f2..24677c474 100644 --- a/Sources/Runestone/Library/Caret.swift +++ b/Sources/Runestone/Library/Caret.swift @@ -4,6 +4,6 @@ enum Caret { static let width: CGFloat = 2 static func defaultHeight(for font: UIFont?) -> CGFloat { - return font?.lineHeight ?? 15 + font?.lineHeight ?? 15 } } diff --git a/Sources/Runestone/Library/NSString+Helpers.swift b/Sources/Runestone/Library/NSString+Helpers.swift index ad123eaf4..142eaaa0d 100644 --- a/Sources/Runestone/Library/NSString+Helpers.swift +++ b/Sources/Runestone/Library/NSString+Helpers.swift @@ -2,7 +2,7 @@ import Foundation extension NSString { var byteCount: ByteCount { - return ByteCount(length * 2) + ByteCount(length * 2) } func getAllBytes(withEncoding encoding: String.Encoding, usedLength: inout Int) -> UnsafePointer? { @@ -48,6 +48,6 @@ extension NSString { private extension NSString { private func isCRLFLineEnding(in range: NSRange) -> Bool { - return substring(with: range) == Symbol.carriageReturnLineFeed + substring(with: range) == Symbol.carriageReturnLineFeed } } diff --git a/Sources/Runestone/Library/String+Helpers.swift b/Sources/Runestone/Library/String+Helpers.swift index e9135a0ca..deac2398b 100644 --- a/Sources/Runestone/Library/String+Helpers.swift +++ b/Sources/Runestone/Library/String+Helpers.swift @@ -9,6 +9,6 @@ extension String { } var byteCount: ByteCount { - return ByteCount(utf16.count * 2) + ByteCount(utf16.count * 2) } } diff --git a/Sources/Runestone/Library/UIFont+Helpers.swift b/Sources/Runestone/Library/UIFont+Helpers.swift index b1257cc17..7428addac 100644 --- a/Sources/Runestone/Library/UIFont+Helpers.swift +++ b/Sources/Runestone/Library/UIFont+Helpers.swift @@ -2,6 +2,6 @@ import UIKit extension UIFont { var totalLineHeight: CGFloat { - return ascender + abs(descender) + leading + ascender + abs(descender) + leading } } diff --git a/Sources/Runestone/Library/UIScrollView+Helpers.swift b/Sources/Runestone/Library/UIScrollView+Helpers.swift index 8231e9297..055600d56 100644 --- a/Sources/Runestone/Library/UIScrollView+Helpers.swift +++ b/Sources/Runestone/Library/UIScrollView+Helpers.swift @@ -2,7 +2,7 @@ import UIKit extension UIScrollView { var minimumContentOffset: CGPoint { - return CGPoint(x: adjustedContentInset.left * -1, y: adjustedContentInset.top * -1) + CGPoint(x: adjustedContentInset.left * -1, y: adjustedContentInset.top * -1) } var maximumContentOffset: CGPoint { diff --git a/Sources/Runestone/LineManager/DocumentLineNodeData.swift b/Sources/Runestone/LineManager/DocumentLineNodeData.swift index 22fc31ba1..e68044078 100644 --- a/Sources/Runestone/LineManager/DocumentLineNodeData.swift +++ b/Sources/Runestone/LineManager/DocumentLineNodeData.swift @@ -9,20 +9,20 @@ final class DocumentLineNodeData { } var totalLength = 0 var length: Int { - return totalLength - delimiterLength + totalLength - delimiterLength } var lineHeight: CGFloat var totalLineHeight: CGFloat = 0 var nodeTotalByteCount = ByteCount(0) var startByte: ByteCount { - return node!.tree.startByte(of: node!) + node!.tree.startByte(of: node!) } var byteCount = ByteCount(0) var byteRange: ByteRange { - return ByteRange(location: startByte, length: byteCount - ByteCount(delimiterLength)) + ByteRange(location: startByte, length: byteCount - ByteCount(delimiterLength)) } var totalByteRange: ByteRange { - return ByteRange(location: startByte, length: byteCount) + ByteRange(location: startByte, length: byteCount) } weak var node: DocumentLineNode? @@ -34,12 +34,12 @@ final class DocumentLineNodeData { private extension DocumentLineTree { func startByte(of node: Node) -> ByteCount { - return offset(of: node, valueKeyPath: \.data.byteCount, totalValueKeyPath: \.data.nodeTotalByteCount, minimumValue: ByteCount(0)) + offset(of: node, valueKeyPath: \.data.byteCount, totalValueKeyPath: \.data.nodeTotalByteCount, minimumValue: ByteCount(0)) } } extension DocumentLineNodeData: CustomDebugStringConvertible { var debugDescription: String { - return "[DocumentLineNodeData length=\(length) delimiterLength=\(delimiterLength) totalLength=\(totalLength)]" + "[DocumentLineNodeData length=\(length) delimiterLength=\(delimiterLength) totalLength=\(totalLength)]" } } diff --git a/Sources/Runestone/LineManager/LineChangeSet.swift b/Sources/Runestone/LineManager/LineChangeSet.swift index b4800e2d8..c5c4e4a74 100644 --- a/Sources/Runestone/LineManager/LineChangeSet.swift +++ b/Sources/Runestone/LineManager/LineChangeSet.swift @@ -32,6 +32,6 @@ final class LineChangeSet { extension LineChangeSet: CustomDebugStringConvertible { var debugDescription: String { - return "[LineChangeSet insertedLines=\(insertedLines) removedLines=\(removedLines) editedLines=\(editedLines)]" + "[LineChangeSet insertedLines=\(insertedLines) removedLines=\(removedLines) editedLines=\(editedLines)]" } } diff --git a/Sources/Runestone/LineManager/LineManager.swift b/Sources/Runestone/LineManager/LineManager.swift index 677fadb2f..b4ce3d731 100644 --- a/Sources/Runestone/LineManager/LineManager.swift +++ b/Sources/Runestone/LineManager/LineManager.swift @@ -4,13 +4,13 @@ import Foundation struct DocumentLineNodeID: RedBlackTreeNodeID, Hashable { let id = UUID() var rawValue: String { - return id.uuidString + id.uuidString } } extension DocumentLineNodeID: CustomDebugStringConvertible { var debugDescription: String { - return rawValue + rawValue } } @@ -20,7 +20,7 @@ typealias DocumentLineNode = RedBlackTreeNode DocumentLineNode? { - return documentLineTree.node(containingLocation: yOffset, - minimumValue: 0, - valueKeyPath: \.data.lineHeight, - totalValueKeyPath: \.data.totalLineHeight) + documentLineTree.node(containingLocation: yOffset, + minimumValue: 0, + valueKeyPath: \.data.lineHeight, + totalValueKeyPath: \.data.totalLineHeight) } func line(containingByteAt byteIndex: ByteCount) -> DocumentLineNode? { - return documentLineTree.node(containingLocation: byteIndex, - minimumValue: ByteCount(0), - valueKeyPath: \.data.byteCount, - totalValueKeyPath: \.data.nodeTotalByteCount) + documentLineTree.node(containingLocation: byteIndex, + minimumValue: ByteCount(0), + valueKeyPath: \.data.byteCount, + totalValueKeyPath: \.data.nodeTotalByteCount) } func line(atRow row: Int) -> DocumentLineNode { - return documentLineTree.node(atIndex: row) + documentLineTree.node(atIndex: row) } @discardableResult @@ -277,7 +277,7 @@ final class LineManager { } func createLineIterator() -> RedBlackTreeIterator { - return RedBlackTreeIterator(tree: documentLineTree) + RedBlackTreeIterator(tree: documentLineTree) } } @@ -368,7 +368,7 @@ extension DocumentLineTree { extension DocumentLineNode { var yPosition: CGFloat { - return tree.yPosition(of: self) + tree.yPosition(of: self) } var range: ClosedRange { diff --git a/Sources/Runestone/LineManager/LinePosition.swift b/Sources/Runestone/LineManager/LinePosition.swift index 8948ce726..6e9ddbe17 100644 --- a/Sources/Runestone/LineManager/LinePosition.swift +++ b/Sources/Runestone/LineManager/LinePosition.swift @@ -16,7 +16,7 @@ final class LinePosition: Hashable, Equatable { } static func == (lhs: LinePosition, rhs: LinePosition) -> Bool { - return lhs.row == rhs.row && lhs.column == rhs.column + lhs.row == rhs.row && lhs.column == rhs.column } func hash(into hasher: inout Hasher) { @@ -27,6 +27,6 @@ final class LinePosition: Hashable, Equatable { extension LinePosition: CustomDebugStringConvertible { var debugDescription: String { - return "[LinePosition row=\(row) column=\(column)]" + "[LinePosition row=\(row) column=\(column)]" } } diff --git a/Sources/Runestone/RedBlackTree/ClosedRangeValueDescriptor.swift b/Sources/Runestone/RedBlackTree/ClosedRangeValueDescriptor.swift index 4d3a1ad43..7c2a77aa1 100644 --- a/Sources/Runestone/RedBlackTree/ClosedRangeValueDescriptor.swift +++ b/Sources/Runestone/RedBlackTree/ClosedRangeValueDescriptor.swift @@ -9,11 +9,11 @@ final class ClosedRangeValueSearchQuery) -> Bool { - return location(of: node) > range.upperBound + location(of: node) > range.upperBound } func shouldTraverseRightChildren(of node: RedBlackTreeNode) -> Bool { - return location(of: node) + node.value < range.upperBound + location(of: node) + node.value < range.upperBound } func shouldInclude(_ node: RedBlackTreeNode) -> Bool { diff --git a/Sources/Runestone/RedBlackTree/RedBlackTree.swift b/Sources/Runestone/RedBlackTree/RedBlackTree.swift index e63d13e9b..0d174e495 100644 --- a/Sources/Runestone/RedBlackTree/RedBlackTree.swift +++ b/Sources/Runestone/RedBlackTree/RedBlackTree.swift @@ -7,10 +7,10 @@ final class RedBlackTree? @@ -119,7 +119,7 @@ final class RedBlackTree NodeValue { - return offset(of: node, valueKeyPath: \.value, totalValueKeyPath: \.nodeTotalValue, minimumValue: minimumValue) + offset(of: node, valueKeyPath: \.value, totalValueKeyPath: \.nodeTotalValue, minimumValue: minimumValue) } func offset(of node: Node, valueKeyPath: KeyPath, totalValueKeyPath: KeyPath, minimumValue: T) -> T { @@ -482,7 +482,7 @@ private extension RedBlackTree { } private func getColor(of node: Node?) -> RedBlackTreeNodeColor { - return node?.color ?? .black + node?.color ?? .black } private func buildTree(from nodes: [Node], start: Int, end: Int, subtreeHeight: Int) -> Node? { @@ -514,7 +514,7 @@ private extension RedBlackTree { extension RedBlackTree: CustomDebugStringConvertible { var debugDescription: String { - return append(root, to: "", indent: 0) + append(root, to: "", indent: 0) } private func append(_ node: Node, to string: String, indent: Int) -> String { @@ -552,6 +552,6 @@ extension RedBlackTree where NodeData == Void { @discardableResult func insertNode(value: NodeValue, after existingNode: Node) -> Node { - return insertNode(value: value, data: (), after: existingNode) + insertNode(value: value, data: (), after: existingNode) } } diff --git a/Sources/Runestone/RedBlackTree/RedBlackTreeChildrenUpdater.swift b/Sources/Runestone/RedBlackTree/RedBlackTreeChildrenUpdater.swift index 6bb1eb123..954e6e20b 100644 --- a/Sources/Runestone/RedBlackTree/RedBlackTreeChildrenUpdater.swift +++ b/Sources/Runestone/RedBlackTree/RedBlackTreeChildrenUpdater.swift @@ -4,6 +4,6 @@ class RedBlackTreeChildrenUpdater func updateAfterChangingChildren(of node: Node) -> Bool { - return false + false } } diff --git a/Sources/Runestone/RedBlackTree/RedBlackTreeNode.swift b/Sources/Runestone/RedBlackTree/RedBlackTreeNode.swift index 61ca2db59..6661611fd 100644 --- a/Sources/Runestone/RedBlackTree/RedBlackTreeNode.swift +++ b/Sources/Runestone/RedBlackTree/RedBlackTreeNode.swift @@ -13,11 +13,11 @@ final class RedBlackTreeNode, rhs: RedBlackTreeNode) -> Bool { - return lhs.id == rhs.id + lhs.id == rhs.id } func hash(into hasher: inout Hasher) { @@ -104,6 +104,6 @@ extension RedBlackTreeNode where NodeData == Void { extension RedBlackTreeNode: CustomDebugStringConvertible { var debugDescription: String { - return "[RedBlackTreeNode index=\(index) location=\(location) nodeTotalCount=\(nodeTotalCount)]" + "[RedBlackTreeNode index=\(index) location=\(location) nodeTotalCount=\(nodeTotalCount)]" } } diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index ebdfa4bbc..82dcee945 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -66,27 +66,27 @@ public protocol Theme: AnyObject { public extension Theme { var gutterHairlineWidth: CGFloat { - return 1 / UIScreen.main.scale + 1 / UIScreen.main.scale } var pageGuideHairlineWidth: CGFloat { - return 1 / UIScreen.main.scale + 1 / UIScreen.main.scale } var markedTextBackgroundCornerRadius: CGFloat { - return 0 + 0 } func font(for highlightName: String) -> UIFont? { - return nil + nil } func fontTraits(for highlightName: String) -> FontTraits { - return [] + [] } func shadow(for highlightName: String) -> NSShadow? { - return nil + nil } #if compiler(>=5.7) diff --git a/Sources/Runestone/TextView/Core/ContentSizeService.swift b/Sources/Runestone/TextView/Core/ContentSizeService.swift index 577f6bc82..7efa25cd3 100644 --- a/Sources/Runestone/TextView/Core/ContentSizeService.swift +++ b/Sources/Runestone/TextView/Core/ContentSizeService.swift @@ -45,10 +45,10 @@ final class ContentSizeService { } } var contentHeight: CGFloat { - return ceil(totalLinesHeight + textContainerInset.top + textContainerInset.bottom) + ceil(totalLinesHeight + textContainerInset.top + textContainerInset.bottom) } var contentSize: CGSize { - return CGSize(width: contentWidth, height: contentHeight) + CGSize(width: contentWidth, height: contentHeight) } @Published private(set) var isContentSizeInvalid = false diff --git a/Sources/Runestone/TextView/Core/EditMenuController.swift b/Sources/Runestone/TextView/Core/EditMenuController.swift index 6253bfc47..ede4ffe98 100644 --- a/Sources/Runestone/TextView/Core/EditMenuController.swift +++ b/Sources/Runestone/TextView/Core/EditMenuController.swift @@ -14,7 +14,7 @@ final class EditMenuController: NSObject { #if compiler(>=5.7) @available(iOS 16, *) private var editMenuInteraction: UIEditMenuInteraction? { - return _editMenuInteraction as? UIEditMenuInteraction + _editMenuInteraction as? UIEditMenuInteraction } private var _editMenuInteraction: Any? #endif @@ -77,15 +77,15 @@ private extension EditMenuController { } private func highlightedRange(for range: NSRange) -> HighlightedRange? { - return delegate?.editMenuController(self, highlightedRangeFor: range) + delegate?.editMenuController(self, highlightedRangeFor: range) } private func canReplaceText(in highlightedRange: HighlightedRange) -> Bool { - return delegate?.editMenuController(self, canReplaceTextIn: highlightedRange) ?? false + delegate?.editMenuController(self, canReplaceTextIn: highlightedRange) ?? false } private func caretRect(at location: Int) -> CGRect { - return delegate?.editMenuController(self, caretRectAt: location) ?? .zero + delegate?.editMenuController(self, caretRectAt: location) ?? .zero } private func replaceActionIfAvailable(for range: NSRange) -> UIAction? { diff --git a/Sources/Runestone/TextView/Core/IndexedRange.swift b/Sources/Runestone/TextView/Core/IndexedRange.swift index 15fd13fd4..5cab9f466 100644 --- a/Sources/Runestone/TextView/Core/IndexedRange.swift +++ b/Sources/Runestone/TextView/Core/IndexedRange.swift @@ -3,13 +3,13 @@ import UIKit final class IndexedRange: UITextRange { let range: NSRange override var start: UITextPosition { - return IndexedPosition(index: range.location) + IndexedPosition(index: range.location) } override var end: UITextPosition { - return IndexedPosition(index: range.location + range.length) + IndexedPosition(index: range.location + range.length) } override var isEmpty: Bool { - return range.length == 0 + range.length == 0 } init(_ range: NSRange) { diff --git a/Sources/Runestone/TextView/Core/StringView.swift b/Sources/Runestone/TextView/Core/StringView.swift index c494b1fa2..d00356e5d 100644 --- a/Sources/Runestone/TextView/Core/StringView.swift +++ b/Sources/Runestone/TextView/Core/StringView.swift @@ -14,7 +14,7 @@ final class StringViewBytesResult { final class StringView { var string: NSString { get { - return internalString + internalString } set { internalString = NSMutableString(string: newValue) diff --git a/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift index 685c32d9c..3d3a87475 100644 --- a/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift @@ -6,7 +6,7 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { private let lineControllerStorage: LineControllerStorage private var newlineCharacters: [Character] { - return [Symbol.Character.lineFeed, Symbol.Character.carriageReturn, Symbol.Character.carriageReturnLineFeed] + [Symbol.Character.lineFeed, Symbol.Character.carriageReturn, Symbol.Character.carriageReturnLineFeed] } init(textInput: UIResponder & UITextInput, stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { @@ -31,7 +31,7 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { override func isPosition(_ position: UITextPosition, withinTextUnit granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool { - return super.isPosition(position, withinTextUnit: granularity, inDirection: direction) + super.isPosition(position, withinTextUnit: granularity, inDirection: direction) } override func position(from position: UITextPosition, @@ -51,7 +51,7 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { override func rangeEnclosingPosition(_ position: UITextPosition, with granularity: UITextGranularity, inDirection direction: UITextDirection) -> UITextRange? { - return super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) + super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) } } @@ -267,7 +267,7 @@ private extension TextInputStringTokenizer { private extension UITextDirection { var isForward: Bool { - return rawValue == UITextStorageDirection.forward.rawValue + rawValue == UITextStorageDirection.forward.rawValue || rawValue == UITextLayoutDirection.right.rawValue || rawValue == UITextLayoutDirection.down.rawValue } @@ -275,6 +275,6 @@ private extension UITextDirection { private extension CharacterSet { func contains(_ character: Character) -> Bool { - return character.unicodeScalars.allSatisfy(contains(_:)) + character.unicodeScalars.allSatisfy(contains(_:)) } } diff --git a/Sources/Runestone/TextView/Core/TextInputView.swift b/Sources/Runestone/TextView/Core/TextInputView.swift index 728a83f3b..a512f836c 100644 --- a/Sources/Runestone/TextView/Core/TextInputView.swift +++ b/Sources/Runestone/TextView/Core/TextInputView.swift @@ -72,17 +72,17 @@ final class TextInputView: UIView, UITextInput { } var markedTextStyle: [NSAttributedString.Key: Any]? var beginningOfDocument: UITextPosition { - return IndexedPosition(index: 0) + IndexedPosition(index: 0) } var endOfDocument: UITextPosition { - return IndexedPosition(index: string.length) + IndexedPosition(index: string.length) } weak var inputDelegate: UITextInputDelegate? var hasText: Bool { - return string.length > 0 + string.length > 0 } var tokenizer: UITextInputTokenizer { - return customTokenizer + customTokenizer } private lazy var customTokenizer = TextInputStringTokenizer(textInput: self, stringView: stringView, @@ -126,7 +126,7 @@ final class TextInputView: UIView, UITextInput { } } override var undoManager: UndoManager? { - return timedUndoManager + timedUndoManager } // MARK: - Appearance @@ -148,7 +148,7 @@ final class TextInputView: UIView, UITextInput { } var lineSelectionDisplayType: LineSelectionDisplayType { get { - return layoutManager.lineSelectionDisplayType + layoutManager.lineSelectionDisplayType } set { layoutManager.lineSelectionDisplayType = newValue @@ -156,7 +156,7 @@ final class TextInputView: UIView, UITextInput { } var showTabs: Bool { get { - return invisibleCharacterConfiguration.showTabs + invisibleCharacterConfiguration.showTabs } set { if newValue != invisibleCharacterConfiguration.showTabs { @@ -167,7 +167,7 @@ final class TextInputView: UIView, UITextInput { } var showSpaces: Bool { get { - return invisibleCharacterConfiguration.showSpaces + invisibleCharacterConfiguration.showSpaces } set { if newValue != invisibleCharacterConfiguration.showSpaces { @@ -178,7 +178,7 @@ final class TextInputView: UIView, UITextInput { } var showNonBreakingSpaces: Bool { get { - return invisibleCharacterConfiguration.showNonBreakingSpaces + invisibleCharacterConfiguration.showNonBreakingSpaces } set { if newValue != invisibleCharacterConfiguration.showNonBreakingSpaces { @@ -189,7 +189,7 @@ final class TextInputView: UIView, UITextInput { } var showLineBreaks: Bool { get { - return invisibleCharacterConfiguration.showLineBreaks + invisibleCharacterConfiguration.showLineBreaks } set { if newValue != invisibleCharacterConfiguration.showLineBreaks { @@ -203,7 +203,7 @@ final class TextInputView: UIView, UITextInput { } var showSoftLineBreaks: Bool { get { - return invisibleCharacterConfiguration.showSoftLineBreaks + invisibleCharacterConfiguration.showSoftLineBreaks } set { if newValue != invisibleCharacterConfiguration.showSoftLineBreaks { @@ -217,7 +217,7 @@ final class TextInputView: UIView, UITextInput { } var tabSymbol: String { get { - return invisibleCharacterConfiguration.tabSymbol + invisibleCharacterConfiguration.tabSymbol } set { if newValue != invisibleCharacterConfiguration.tabSymbol { @@ -228,7 +228,7 @@ final class TextInputView: UIView, UITextInput { } var spaceSymbol: String { get { - return invisibleCharacterConfiguration.spaceSymbol + invisibleCharacterConfiguration.spaceSymbol } set { if newValue != invisibleCharacterConfiguration.spaceSymbol { @@ -239,7 +239,7 @@ final class TextInputView: UIView, UITextInput { } var nonBreakingSpaceSymbol: String { get { - return invisibleCharacterConfiguration.nonBreakingSpaceSymbol + invisibleCharacterConfiguration.nonBreakingSpaceSymbol } set { if newValue != invisibleCharacterConfiguration.nonBreakingSpaceSymbol { @@ -250,7 +250,7 @@ final class TextInputView: UIView, UITextInput { } var lineBreakSymbol: String { get { - return invisibleCharacterConfiguration.lineBreakSymbol + invisibleCharacterConfiguration.lineBreakSymbol } set { if newValue != invisibleCharacterConfiguration.lineBreakSymbol { @@ -261,7 +261,7 @@ final class TextInputView: UIView, UITextInput { } var softLineBreakSymbol: String { get { - return invisibleCharacterConfiguration.softLineBreakSymbol + invisibleCharacterConfiguration.softLineBreakSymbol } set { if newValue != invisibleCharacterConfiguration.softLineBreakSymbol { @@ -309,7 +309,7 @@ final class TextInputView: UIView, UITextInput { } var textContainerInset: UIEdgeInsets { get { - return layoutManager.textContainerInset + layoutManager.textContainerInset } set { if newValue != layoutManager.textContainerInset { @@ -324,7 +324,7 @@ final class TextInputView: UIView, UITextInput { } var isLineWrappingEnabled: Bool { get { - return layoutManager.isLineWrappingEnabled + layoutManager.isLineWrappingEnabled } set { if newValue != layoutManager.isLineWrappingEnabled { @@ -347,7 +347,7 @@ final class TextInputView: UIView, UITextInput { } } var gutterWidth: CGFloat { - return gutterWidthService.gutterWidth + gutterWidthService.gutterWidth } var lineHeightMultiplier: CGFloat = 1 { didSet { @@ -394,7 +394,7 @@ final class TextInputView: UIView, UITextInput { } var pageGuideColumn: Int { get { - return pageGuideController.column + pageGuideController.column } set { if newValue != pageGuideController.column { @@ -404,11 +404,11 @@ final class TextInputView: UIView, UITextInput { } } private var estimatedLineHeight: CGFloat { - return theme.font.totalLineHeight * lineHeightMultiplier + theme.font.totalLineHeight * lineHeightMultiplier } var highlightedRanges: [HighlightedRange] { get { - return highlightService.highlightedRanges + highlightService.highlightedRanges } set { if newValue != highlightService.highlightedRanges { @@ -423,7 +423,7 @@ final class TextInputView: UIView, UITextInput { weak var delegate: TextInputViewDelegate? var string: NSString { get { - return stringView.string + stringView.string } set { if newValue != stringView.string { @@ -448,7 +448,7 @@ final class TextInputView: UIView, UITextInput { } var viewport: CGRect { get { - return layoutManager.viewport + layoutManager.viewport } set { if newValue != layoutManager.viewport { @@ -470,11 +470,11 @@ final class TextInputView: UIView, UITextInput { } } var contentSize: CGSize { - return contentSizeService.contentSize + contentSizeService.contentSize } var selectedRange: NSRange? { get { - return _selectedRange + _selectedRange } set { if newValue != _selectedRange { @@ -493,11 +493,11 @@ final class TextInputView: UIView, UITextInput { } } override var canBecomeFirstResponder: Bool { - return true + true } weak var gutterParentView: UIView? { get { - return layoutManager.gutterParentView + layoutManager.gutterParentView } set { layoutManager.gutterParentView = newValue @@ -511,7 +511,7 @@ final class TextInputView: UIView, UITextInput { } } var gutterContainerView: UIView { - return layoutManager.gutterContainerView + layoutManager.gutterContainerView } private(set) var stringView = StringView() { didSet { @@ -542,7 +542,7 @@ final class TextInputView: UIView, UITextInput { } } var viewHierarchyContainsCaret: Bool { - return textSelectionView?.subviews.count == 1 + textSelectionView?.subviews.count == 1 } var lineEndings: LineEnding = .lf private(set) var isRestoringPreviouslyDeletedText = false @@ -573,7 +573,7 @@ final class TextInputView: UIView, UITextInput { private let invisibleCharacterConfiguration = InvisibleCharacterConfiguration() private var markedRange: NSRange? { get { - return layoutManager.markedRange + layoutManager.markedRange } set { layoutManager.markedRange = newValue @@ -771,7 +771,7 @@ final class TextInputView: UIView, UITextInput { } func linePosition(at location: Int) -> LinePosition? { - return lineManager.linePosition(at: location) + lineManager.linePosition(at: location) } func setState(_ state: TextViewState, addUndoAction: Bool = false) { @@ -859,11 +859,11 @@ final class TextInputView: UIView, UITextInput { } func detectIndentStrategy() -> DetectedIndentStrategy { - return languageMode.detectIndentStrategy() + languageMode.detectIndentStrategy() } func textPreview(containing range: NSRange) -> TextPreview? { - return layoutManager.textPreview(containing: range) + layoutManager.textPreview(containing: range) } func layoutLines(toLocation location: Int) { @@ -1073,7 +1073,7 @@ extension TextInputView { } func caretRect(at location: Int) -> CGRect { - return caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: true) + caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: true) } func firstRect(for range: UITextRange) -> CGRect { @@ -1195,7 +1195,7 @@ extension TextInputView { } func text(in range: NSRange) -> String? { - return stringView.substring(in: range) + stringView.substring(in: range) } private func setStringWithUndoAction(_ newString: NSString) { @@ -1288,7 +1288,7 @@ extension TextInputView { } private func shouldChangeText(in range: NSRange, replacementText text: String) -> Bool { - return delegate?.textInputView(self, shouldChangeTextIn: range, replacementText: text) ?? true + delegate?.textInputView(self, shouldChangeTextIn: range, replacementText: text) ?? true } private func addUndoOperation(replacing range: NSRange, @@ -1551,7 +1551,7 @@ extension TextInputView { // MARK: - Writing Direction extension TextInputView { func baseWritingDirection(for position: UITextPosition, in direction: UITextStorageDirection) -> NSWritingDirection { - return .natural + .natural } func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) {} @@ -1560,7 +1560,7 @@ extension TextInputView { // MARK: - UIEditMenuInteraction extension TextInputView { func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? { - return editMenuController.editMenu(for: textRange, suggestedActions: suggestedActions) + editMenuController.editMenu(for: textRange, suggestedActions: suggestedActions) } func presentEditMenuForText(in range: NSRange) { @@ -1574,7 +1574,7 @@ extension TextInputView { } private func highlightedRange(for range: NSRange) -> HighlightedRange? { - return highlightedRanges.first { $0.range == range } + highlightedRanges.first { $0.range == range } } } @@ -1649,7 +1649,7 @@ extension TextInputView: IndentControllerDelegate { // MARK: - EditMenuControllerDelegate extension TextInputView: EditMenuControllerDelegate { func editMenuController(_ controller: EditMenuController, caretRectAt location: Int) -> CGRect { - return caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: false) + caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: false) } func editMenuControllerShouldReplaceText(_ controller: EditMenuController) { @@ -1657,14 +1657,14 @@ extension TextInputView: EditMenuControllerDelegate { } func editMenuController(_ controller: EditMenuController, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - return delegate?.textInputView(self, canReplaceTextIn: highlightedRange) ?? false + delegate?.textInputView(self, canReplaceTextIn: highlightedRange) ?? false } func editMenuController(_ controller: EditMenuController, highlightedRangeFor range: NSRange) -> HighlightedRange? { - return highlightedRange(for: range) + highlightedRange(for: range) } func selectedRange(for controller: EditMenuController) -> NSRange? { - return selectedRange + selectedRange } } diff --git a/Sources/Runestone/TextView/Core/TextView.swift b/Sources/Runestone/TextView/Core/TextView.swift index fba2e957d..7fec3b2c3 100644 --- a/Sources/Runestone/TextView/Core/TextView.swift +++ b/Sources/Runestone/TextView/Core/TextView.swift @@ -24,7 +24,7 @@ open class TextView: UIScrollView { /// The text that the text view displays. public var text: String { get { - return textInputView.string as String + textInputView.string as String } set { textInputView.string = newValue as NSString @@ -56,7 +56,7 @@ open class TextView: UIScrollView { /// Colors and fonts to be used by the editor. public var theme: Theme { get { - return textInputView.theme + textInputView.theme } set { textInputView.theme = newValue @@ -65,7 +65,7 @@ open class TextView: UIScrollView { /// The autocorrection style for the text view. public var autocorrectionType: UITextAutocorrectionType { get { - return textInputView.autocorrectionType + textInputView.autocorrectionType } set { textInputView.autocorrectionType = newValue @@ -74,7 +74,7 @@ open class TextView: UIScrollView { /// The autocapitalization style for the text view. public var autocapitalizationType: UITextAutocapitalizationType { get { - return textInputView.autocapitalizationType + textInputView.autocapitalizationType } set { textInputView.autocapitalizationType = newValue @@ -83,7 +83,7 @@ open class TextView: UIScrollView { /// The spell-checking style for the text view. public var smartQuotesType: UITextSmartQuotesType { get { - return textInputView.smartQuotesType + textInputView.smartQuotesType } set { textInputView.smartQuotesType = newValue @@ -92,7 +92,7 @@ open class TextView: UIScrollView { /// The configuration state for smart dashes. public var smartDashesType: UITextSmartDashesType { get { - return textInputView.smartDashesType + textInputView.smartDashesType } set { textInputView.smartDashesType = newValue @@ -101,7 +101,7 @@ open class TextView: UIScrollView { /// The configuration state for the smart insertion and deletion of space characters. public var smartInsertDeleteType: UITextSmartInsertDeleteType { get { - return textInputView.smartInsertDeleteType + textInputView.smartInsertDeleteType } set { textInputView.smartInsertDeleteType = newValue @@ -110,7 +110,7 @@ open class TextView: UIScrollView { /// The spell-checking style for the text object. public var spellCheckingType: UITextSpellCheckingType { get { - return textInputView.spellCheckingType + textInputView.spellCheckingType } set { textInputView.spellCheckingType = newValue @@ -119,7 +119,7 @@ open class TextView: UIScrollView { /// The keyboard type for the text view. public var keyboardType: UIKeyboardType { get { - return textInputView.keyboardType + textInputView.keyboardType } set { textInputView.keyboardType = newValue @@ -128,7 +128,7 @@ open class TextView: UIScrollView { /// The appearance style of the keyboard for the text view. public var keyboardAppearance: UIKeyboardAppearance { get { - return textInputView.keyboardAppearance + textInputView.keyboardAppearance } set { textInputView.keyboardAppearance = newValue @@ -137,7 +137,7 @@ open class TextView: UIScrollView { /// The display of the return key. public var returnKeyType: UIReturnKeyType { get { - return textInputView.returnKeyType + textInputView.returnKeyType } set { textInputView.returnKeyType = newValue @@ -145,12 +145,12 @@ open class TextView: UIScrollView { } /// Returns the undo manager used by the text view. override public var undoManager: UndoManager? { - return textInputView.undoManager + textInputView.undoManager } /// The color of the insertion point. This can be used to control the color of the caret. public var insertionPointColor: UIColor { get { - return textInputView.insertionPointColor + textInputView.insertionPointColor } set { textInputView.insertionPointColor = newValue @@ -159,7 +159,7 @@ open class TextView: UIScrollView { /// The color of the selection bar. It is most common to set this to the same color as the color used for the insertion point. public var selectionBarColor: UIColor { get { - return textInputView.selectionBarColor + textInputView.selectionBarColor } set { textInputView.selectionBarColor = newValue @@ -168,7 +168,7 @@ open class TextView: UIScrollView { /// The color of the selection highlight. It is most common to set this to the same color as the color used for the insertion point. public var selectionHighlightColor: UIColor { get { - return textInputView.selectionHighlightColor + textInputView.selectionHighlightColor } set { textInputView.selectionHighlightColor = newValue @@ -191,7 +191,7 @@ open class TextView: UIScrollView { /// The current selection range of the text view as a UITextRange. public var selectedTextRange: UITextRange? { get { - return textInputView.selectedTextRange + textInputView.selectedTextRange } set { textInputView.selectedTextRange = newValue @@ -212,16 +212,16 @@ open class TextView: UIScrollView { } /// The input assistant to use when configuring the keyboard's shortcuts bar. override public var inputAssistantItem: UITextInputAssistantItem { - return textInputView.inputAssistantItem + textInputView.inputAssistantItem } /// Returns a Boolean value indicating whether this object can become the first responder. override public var canBecomeFirstResponder: Bool { - return !textInputView.isFirstResponder && isEditable + !textInputView.isFirstResponder && isEditable } /// The text view's background color. override public var backgroundColor: UIColor? { get { - return textInputView.backgroundColor + textInputView.backgroundColor } set { super.backgroundColor = newValue @@ -241,7 +241,7 @@ open class TextView: UIScrollView { /// Common usages of this includes the \" character to surround strings and { } to surround a scope. public var characterPairs: [CharacterPair] { get { - return textInputView.characterPairs + textInputView.characterPairs } set { textInputView.characterPairs = newValue @@ -250,7 +250,7 @@ open class TextView: UIScrollView { /// Determines what should happen to the trailing component of a character pair when deleting the leading component. Defaults to `disabled` meaning that nothing will happen. public var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode { get { - return textInputView.characterPairTrailingComponentDeletionMode + textInputView.characterPairTrailingComponentDeletionMode } set { textInputView.characterPairTrailingComponentDeletionMode = newValue @@ -259,7 +259,7 @@ open class TextView: UIScrollView { /// Enable to show line numbers in the gutter. public var showLineNumbers: Bool { get { - return textInputView.showLineNumbers + textInputView.showLineNumbers } set { textInputView.showLineNumbers = newValue @@ -268,7 +268,7 @@ open class TextView: UIScrollView { /// Enable to show highlight the selected lines. The selection is only shown in the gutter when multiple lines are selected. public var lineSelectionDisplayType: LineSelectionDisplayType { get { - return textInputView.lineSelectionDisplayType + textInputView.lineSelectionDisplayType } set { textInputView.lineSelectionDisplayType = newValue @@ -277,7 +277,7 @@ open class TextView: UIScrollView { /// The text view renders invisible tabs when enabled. The `tabsSymbol` is used to render tabs. public var showTabs: Bool { get { - return textInputView.showTabs + textInputView.showTabs } set { textInputView.showTabs = newValue @@ -288,7 +288,7 @@ open class TextView: UIScrollView { /// he `spaceSymbol` is used to render spaces. public var showSpaces: Bool { get { - return textInputView.showSpaces + textInputView.showSpaces } set { textInputView.showSpaces = newValue @@ -299,7 +299,7 @@ open class TextView: UIScrollView { /// The `nonBreakingSpaceSymbol` is used to render spaces. public var showNonBreakingSpaces: Bool { get { - return textInputView.showNonBreakingSpaces + textInputView.showNonBreakingSpaces } set { textInputView.showNonBreakingSpaces = newValue @@ -310,7 +310,7 @@ open class TextView: UIScrollView { /// The `lineBreakSymbol` is used to render line breaks. public var showLineBreaks: Bool { get { - return textInputView.showLineBreaks + textInputView.showLineBreaks } set { textInputView.showLineBreaks = newValue @@ -321,7 +321,7 @@ open class TextView: UIScrollView { /// The `softLineBreakSymbol` is used to render line breaks. These line breaks are typically represented by the U+2028 unicode character. Runestone does not provide any key commands for inserting these but supports rendering them. public var showSoftLineBreaks: Bool { get { - return textInputView.showSoftLineBreaks + textInputView.showSoftLineBreaks } set { textInputView.showSoftLineBreaks = newValue @@ -334,7 +334,7 @@ open class TextView: UIScrollView { /// Common characters for this symbol include ▸, ⇥, ➜, ➞, and ❯. public var tabSymbol: String { get { - return textInputView.tabSymbol + textInputView.tabSymbol } set { textInputView.tabSymbol = newValue @@ -347,7 +347,7 @@ open class TextView: UIScrollView { /// Common characters for this symbol include ·, •, and _. public var spaceSymbol: String { get { - return textInputView.spaceSymbol + textInputView.spaceSymbol } set { textInputView.spaceSymbol = newValue @@ -360,7 +360,7 @@ open class TextView: UIScrollView { /// Common characters for this symbol include ·, •, and _. public var nonBreakingSpaceSymbol: String { get { - return textInputView.nonBreakingSpaceSymbol + textInputView.nonBreakingSpaceSymbol } set { textInputView.nonBreakingSpaceSymbol = newValue @@ -373,7 +373,7 @@ open class TextView: UIScrollView { /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. public var lineBreakSymbol: String { get { - return textInputView.lineBreakSymbol + textInputView.lineBreakSymbol } set { textInputView.lineBreakSymbol = newValue @@ -386,7 +386,7 @@ open class TextView: UIScrollView { /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. public var softLineBreakSymbol: String { get { - return textInputView.softLineBreakSymbol + textInputView.softLineBreakSymbol } set { textInputView.softLineBreakSymbol = newValue @@ -395,7 +395,7 @@ open class TextView: UIScrollView { /// The strategy used when indenting text. public var indentStrategy: IndentStrategy { get { - return textInputView.indentStrategy + textInputView.indentStrategy } set { textInputView.indentStrategy = newValue @@ -404,7 +404,7 @@ open class TextView: UIScrollView { /// The amount of padding before the line numbers inside the gutter. public var gutterLeadingPadding: CGFloat { get { - return textInputView.gutterLeadingPadding + textInputView.gutterLeadingPadding } set { textInputView.gutterLeadingPadding = newValue @@ -413,7 +413,7 @@ open class TextView: UIScrollView { /// The amount of padding after the line numbers inside the gutter. public var gutterTrailingPadding: CGFloat { get { - return textInputView.gutterTrailingPadding + textInputView.gutterTrailingPadding } set { textInputView.gutterTrailingPadding = newValue @@ -422,7 +422,7 @@ open class TextView: UIScrollView { /// The minimum amount of characters to use for width calculation inside the gutter. public var gutterMinimumCharacterCount: Int { get { - return textInputView.gutterMinimumCharacterCount + textInputView.gutterMinimumCharacterCount } set { textInputView.gutterMinimumCharacterCount = newValue @@ -431,7 +431,7 @@ open class TextView: UIScrollView { /// The amount of spacing surrounding the lines. public var textContainerInset: UIEdgeInsets { get { - return textInputView.textContainerInset + textInputView.textContainerInset } set { textInputView.textContainerInset = newValue @@ -442,7 +442,7 @@ open class TextView: UIScrollView { /// Line wrapping is enabled by default. public var isLineWrappingEnabled: Bool { get { - return textInputView.isLineWrappingEnabled + textInputView.isLineWrappingEnabled } set { textInputView.isLineWrappingEnabled = newValue @@ -451,7 +451,7 @@ open class TextView: UIScrollView { /// Line break mode for text view. The default value is .byWordWrapping meaning that wrapping occurs on word boundaries. public var lineBreakMode: LineBreakMode { get { - return textInputView.lineBreakMode + textInputView.lineBreakMode } set { textInputView.lineBreakMode = newValue @@ -459,12 +459,12 @@ open class TextView: UIScrollView { } /// Width of the gutter. public var gutterWidth: CGFloat { - return textInputView.gutterWidth + textInputView.gutterWidth } /// The line-height is multiplied with the value. public var lineHeightMultiplier: CGFloat { get { - return textInputView.lineHeightMultiplier + textInputView.lineHeightMultiplier } set { textInputView.lineHeightMultiplier = newValue @@ -473,7 +473,7 @@ open class TextView: UIScrollView { /// The number of points by which to adjust kern. The default value is 0 meaning that kerning is disabled. public var kern: CGFloat { get { - return textInputView.kern + textInputView.kern } set { textInputView.kern = newValue @@ -482,7 +482,7 @@ open class TextView: UIScrollView { /// The text view shows a page guide when enabled. Use `pageGuideColumn` to specify the location of the page guide. public var showPageGuide: Bool { get { - return textInputView.showPageGuide + textInputView.showPageGuide } set { textInputView.showPageGuide = newValue @@ -491,7 +491,7 @@ open class TextView: UIScrollView { /// Specifies the location of the page guide. Use `showPageGuide` to specify if the page guide should be shown. public var pageGuideColumn: Int { get { - return textInputView.pageGuideColumn + textInputView.pageGuideColumn } set { textInputView.pageGuideColumn = newValue @@ -525,12 +525,12 @@ open class TextView: UIScrollView { /// /// 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? { - return textInputView.lineManager.initialLongestLine?.data.totalLength + 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 { - return textInputView.highlightedRanges + textInputView.highlightedRanges } set { textInputView.highlightedRanges = newValue @@ -562,7 +562,7 @@ open class TextView: UIScrollView { /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)`` and ``TextView/replaceText(in:)``. public var lineEndings: LineEnding { get { - return textInputView.lineEndings + textInputView.lineEndings } set { textInputView.lineEndings = newValue @@ -577,7 +577,7 @@ open class TextView: UIScrollView { @available(iOS 16, *) public var isFindInteractionEnabled: Bool { get { - return textSearchingHelper.isFindInteractionEnabled + textSearchingHelper.isFindInteractionEnabled } set { textSearchingHelper.isFindInteractionEnabled = newValue @@ -590,7 +590,7 @@ open class TextView: UIScrollView { /// Call [`presentFindNavigator(showingReplace:)`](https://developer.apple.com/documentation/uikit/uifindinteraction/3975832-presentfindnavigator) on the UIFindInteraction object to invoke the find interaction and display the find panel. @available(iOS 16, *) public var findInteraction: UIFindInteraction? { - return textSearchingHelper.findInteraction + textSearchingHelper.findInteraction } #endif @@ -600,7 +600,7 @@ open class TextView: UIScrollView { #if compiler(>=5.7) @available(iOS 16.0, *) private var editMenuInteraction: UIEditMenuInteraction? { - return _editMenuInteraction as? UIEditMenuInteraction + _editMenuInteraction as? UIEditMenuInteraction } private var _editMenuInteraction: Any? #endif @@ -814,7 +814,7 @@ open class TextView: UIScrollView { /// - Parameter range: A range of text in the document. /// - Returns: The substring that falls within the specified range. public func text(in range: NSRange) -> String? { - return textInputView.text(in: range) + textInputView.text(in: range) } /// Returns the syntax node at the specified location in the document. @@ -827,7 +827,7 @@ open class TextView: UIScrollView { /// - Parameter location: A location in the document. /// - Returns: The syntax node at the location. public func syntaxNode(at location: Int) -> SyntaxNode? { - return textInputView.syntaxNode(at: location) + textInputView.syntaxNode(at: location) } /// Checks if the specified locations is within the indentation of the line. @@ -835,7 +835,7 @@ open class TextView: UIScrollView { /// - Parameter location: A location in the document. /// - Returns: True if the location is within the indentation of the line, otherwise false. public func isIndentation(at location: Int) -> Bool { - return textInputView.isIndentation(at: location) + textInputView.isIndentation(at: location) } /// Decreases the indentation level of the selected lines. @@ -865,7 +865,7 @@ open class TextView: UIScrollView { /// Attempts to detect the indent strategy used in the document. This may return an unknown strategy even /// when the document contains indentation. public func detectIndentStrategy() -> DetectedIndentStrategy { - return textInputView.detectIndentStrategy() + textInputView.detectIndentStrategy() } /// Go to the beginning of the line at the specified index. @@ -943,7 +943,7 @@ open class TextView: UIScrollView { /// - Parameter range: Range of text to include in text view. The returned result may span a larger range than the one specified. /// - Returns: Text preview containing the specified range. public func textPreview(containing range: NSRange) -> TextPreview? { - return textInputView.textPreview(containing: range) + textInputView.textPreview(containing: range) } /// Selects a highlighted range behind the selected range if possible. @@ -972,17 +972,17 @@ open class TextView: UIScrollView { extension TextView { /// The range of currently marked text in a document. public var markedTextRange: UITextRange? { - return textInputView.markedTextRange + textInputView.markedTextRange } /// The text position for the beginning of a document. public var beginningOfDocument: UITextPosition { - return textInputView.beginningOfDocument + textInputView.beginningOfDocument } /// The text position for the end of a document. public var endOfDocument: UITextPosition { - return textInputView.endOfDocument + textInputView.endOfDocument } /// Returns the range between two text positions. @@ -991,7 +991,7 @@ extension TextView { /// - toPosition: An object that represents another location in a document. /// - Returns: An object that represents the range between fromPosition and toPosition. public func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { - return textInputView.textRange(from: fromPosition, to: toPosition) + textInputView.textRange(from: fromPosition, to: toPosition) } /// Returns the text position at a specified offset from another text position. @@ -1000,7 +1000,7 @@ extension TextView { /// - offset: A character offset from position. It can be a positive or negative value. /// - Returns: A custom UITextPosition object that represents the location in a document that is at the specified offset from position. Returns nil if the computed text position is less than 0 or greater than the length of the backing string. public func position(from position: UITextPosition, offset: Int) -> UITextPosition? { - return textInputView.position(from: position, offset: offset) + textInputView.position(from: position, offset: offset) } /// Returns the text position at a specified offset in a specified direction from another text position. @@ -1010,7 +1010,7 @@ extension TextView { /// - offset: A character offset from position. /// - Returns: Returns the text position at a specified offset in a specified direction from another text position. Returns nil if the computed text position is less than 0 or greater than the length of the backing string. public func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? { - return textInputView.position(from: position, in: direction, offset: offset) + textInputView.position(from: position, in: direction, offset: offset) } /// Returns how one text position compares to another text position. @@ -1019,7 +1019,7 @@ extension TextView { /// - other: A custom object that represents another location within a document. /// - Returns: A value that indicates whether the two text positions are identical or whether one is before the other. public func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { - return textInputView.compare(position, to: other) + textInputView.compare(position, to: other) } /// Returns the number of UTF-16 characters between one text position and another text position. @@ -1028,12 +1028,12 @@ extension TextView { /// - toPosition: A custom object that represents another location within document. /// - Returns: The number of UTF-16 characters between fromPosition and toPosition. public func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { - return textInputView.offset(from: from, to: toPosition) + textInputView.offset(from: from, to: toPosition) } /// An input tokenizer that provides information about the granularity of text units. public var tokenizer: UITextInputTokenizer { - return textInputView.tokenizer + textInputView.tokenizer } /// Returns the text position that is at the farthest extent in a specified layout direction within a range of text. @@ -1042,7 +1042,7 @@ extension TextView { /// - direction: A constant that indicates a direction of layout (right, left, up, down). /// - Returns: A text-position object that identifies a location in the visible text. public func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { - return textInputView.position(within: range, farthestIn: direction) + textInputView.position(within: range, farthestIn: direction) } /// Returns a text range from a specified text position to its farthest extent in a certain direction of layout. @@ -1051,35 +1051,35 @@ extension TextView { /// - direction: A constant that indicates a direction of layout (right, left, up, down). /// - Returns: A text-range object that represents the distance from position to the farthest extent in direction. public func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { - return textInputView.characterRange(byExtending: position, in: direction) + textInputView.characterRange(byExtending: position, in: direction) } /// Returns the first rectangle that encloses a range of text in a document. /// - Parameter range: An object that represents a range of text in a document. /// - Returns: The first rectangle in a range of text. You might use this rectangle to draw a correction rectangle. The “first” in the name refers the rectangle enclosing the first line when the range encompasses multiple lines of text. public func firstRect(for range: UITextRange) -> CGRect { - return textInputView.firstRect(for: range) + textInputView.firstRect(for: range) } /// Returns a rectangle to draw the caret at a specified insertion point. /// - Parameter position: An object that identifies a location in a text input area. /// - Returns: A rectangle that defines the area for drawing the caret. public func caretRect(for position: UITextPosition) -> CGRect { - return textInputView.caretRect(for: position) + textInputView.caretRect(for: position) } /// Returns an array of selection rects corresponding to the range of text. /// - Parameter range: An object representing a range in a document’s text. /// - Returns: An array of UITextSelectionRect objects that encompass the selection. public func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { - return textInputView.selectionRects(for: range) + textInputView.selectionRects(for: range) } /// Returns the position in a document that is closest to a specified point. /// - Parameter point: A point in the view that is drawing a document’s text. /// - Returns: An object locating a position in a document that is closest to point. public func closestPosition(to point: CGPoint) -> UITextPosition? { - return textInputView.closestPosition(to: point) + textInputView.closestPosition(to: point) } /// Returns the position in a document that is closest to a specified point in a specified range. @@ -1088,26 +1088,26 @@ extension TextView { /// - range: An object representing a range in a document’s text. /// - Returns: An object representing the character position in range that is closest to point. public func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { - return textInputView.closestPosition(to: point, within: range) + textInputView.closestPosition(to: point, within: range) } /// Returns the character or range of characters that is at a specified point in a document. /// - Parameter point: A point in the view that is drawing a document’s text. /// - Returns: An object representing a range that encloses a character (or characters) at point. public func characterRange(at point: CGPoint) -> UITextRange? { - return textInputView.characterRange(at: point) + textInputView.characterRange(at: point) } /// Returns the text in the specified range. /// - Parameter range: A range of text in a document. /// - Returns: A substring of a document that falls within the specified range. public func text(in range: UITextRange) -> String? { - return textInputView.text(in: range) + textInputView.text(in: range) } /// A Boolean value that indicates whether the text-entry object has any text. public var hasText: Bool { - return textInputView.hasText + textInputView.hasText } /// Scrolls the text view to reveal the text in the specified range. @@ -1401,7 +1401,7 @@ extension TextView: TextInputViewDelegate { } func textInputView(_ view: TextInputView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - return editorDelegate?.textView(self, canReplaceTextIn: highlightedRange) ?? false + editorDelegate?.textView(self, canReplaceTextIn: highlightedRange) ?? false } func textInputView(_ view: TextInputView, replaceTextIn highlightedRange: HighlightedRange) { @@ -1434,7 +1434,7 @@ extension TextView: HighlightNavigationControllerDelegate { // MARK: - SearchControllerDelegate extension TextView: SearchControllerDelegate { func searchController(_ searchController: SearchController, linePositionAt location: Int) -> LinePosition? { - return textInputView.lineManager.linePosition(at: location) + textInputView.lineManager.linePosition(at: location) } } diff --git a/Sources/Runestone/TextView/Core/TextViewDelegate.swift b/Sources/Runestone/TextView/Core/TextViewDelegate.swift index 99ab65ec5..c096cdc7d 100644 --- a/Sources/Runestone/TextView/Core/TextViewDelegate.swift +++ b/Sources/Runestone/TextView/Core/TextViewDelegate.swift @@ -105,11 +105,11 @@ public protocol TextViewDelegate: AnyObject { public extension TextViewDelegate { func textViewShouldBeginEditing(_ textView: TextView) -> Bool { - return true + true } func textViewShouldEndEditing(_ textView: TextView) -> Bool { - return true + true } func textViewDidBeginEditing(_ textView: TextView) {} @@ -121,15 +121,15 @@ public extension TextViewDelegate { func textViewDidChangeSelection(_ textView: TextView) {} func textView(_ textView: TextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - return true + true } func textView(_ textView: TextView, shouldInsert characterPair: CharacterPair, in range: NSRange) -> Bool { - return true + true } func textView(_ textView: TextView, shouldSkipTrailingComponentOf characterPair: CharacterPair, in range: NSRange) -> Bool { - return true + true } func textViewDidChangeGutterWidth(_ textView: TextView) {} @@ -143,7 +143,7 @@ public extension TextViewDelegate { func textViewDidLoopToFirstHighlightedRange(_ textView: TextView) {} func textView(_ textView: TextView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - return false + false } func textView(_ textView: TextView, replaceTextIn highlightedRange: HighlightedRange) {} diff --git a/Sources/Runestone/TextView/Core/TimedUndoManager.swift b/Sources/Runestone/TextView/Core/TimedUndoManager.swift index af5610a7b..01493641c 100644 --- a/Sources/Runestone/TextView/Core/TimedUndoManager.swift +++ b/Sources/Runestone/TextView/Core/TimedUndoManager.swift @@ -4,7 +4,7 @@ final class TimedUndoManager: UndoManager { private let endGroupingInterval: TimeInterval = 1 private var endGroupingTimer: Timer? private var hasOpenGroup: Bool { - return groupingLevel > 0 + groupingLevel > 0 } override init() { diff --git a/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift b/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift index 1d4aaeef4..c49079cdb 100644 --- a/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift +++ b/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift @@ -10,7 +10,7 @@ final class GutterBackgroundView: UIView { } var hairlineColor: UIColor? { get { - return hairlineView.backgroundColor + hairlineView.backgroundColor } set { hairlineView.backgroundColor = newValue diff --git a/Sources/Runestone/TextView/Gutter/LineNumberView.swift b/Sources/Runestone/TextView/Gutter/LineNumberView.swift index cdcf15dce..406ccd4b9 100644 --- a/Sources/Runestone/TextView/Gutter/LineNumberView.swift +++ b/Sources/Runestone/TextView/Gutter/LineNumberView.swift @@ -3,7 +3,7 @@ import UIKit final class LineNumberView: UIView, ReusableView { var textColor: UIColor { get { - return titleLabel.textColor + titleLabel.textColor } set { titleLabel.textColor = newValue @@ -11,7 +11,7 @@ final class LineNumberView: UIView, ReusableView { } var font: UIFont { get { - return titleLabel.font + titleLabel.font } set { titleLabel.font = newValue @@ -19,7 +19,7 @@ final class LineNumberView: UIView, ReusableView { } var text: String? { get { - return titleLabel.text + titleLabel.text } set { titleLabel.text = newValue diff --git a/Sources/Runestone/TextView/Highlight/HighlightedRange.swift b/Sources/Runestone/TextView/Highlight/HighlightedRange.swift index d39fcd47a..5f1554155 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightedRange.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightedRange.swift @@ -27,12 +27,12 @@ public final class HighlightedRange { extension HighlightedRange: Equatable { public static func == (lhs: HighlightedRange, rhs: HighlightedRange) -> Bool { - return lhs.id == rhs.id && lhs.range == rhs.range && lhs.color == rhs.color + lhs.id == rhs.id && lhs.range == rhs.range && lhs.color == rhs.color } } extension HighlightedRange: CustomDebugStringConvertible { public var debugDescription: String { - return "[HighightedRange range=\(range)]" + "[HighightedRange range=\(range)]" } } diff --git a/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift b/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift index c5553c962..d72c34502 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift @@ -29,7 +29,7 @@ final class HighlightedRangeFragment: Equatable { extension HighlightedRangeFragment { static func == (lhs: HighlightedRangeFragment, rhs: HighlightedRangeFragment) -> Bool { - return lhs.range == rhs.range + lhs.range == rhs.range && lhs.containsStart == rhs.containsStart && lhs.containsEnd == rhs.containsEnd && lhs.color == rhs.color diff --git a/Sources/Runestone/TextView/LineController/LineController.swift b/Sources/Runestone/TextView/LineController/LineController.swift index 74b18e261..ef15cc0b5 100644 --- a/Sources/Runestone/TextView/LineController/LineController.swift +++ b/Sources/Runestone/TextView/LineController/LineController.swift @@ -35,14 +35,14 @@ final class LineController { var tabWidth: CGFloat = 10 var constrainingWidth: CGFloat { get { - return typesetter.constrainingWidth + typesetter.constrainingWidth } set { typesetter.constrainingWidth = newValue } } var lineWidth: CGFloat { - return ceil(typesetter.maximumLineWidth) + ceil(typesetter.maximumLineWidth) } var lineHeight: CGFloat { if let lineHeight = _lineHeight { @@ -70,17 +70,17 @@ final class LineController { } var lineBreakMode: LineBreakMode { get { - return typesetter.lineBreakMode + typesetter.lineBreakMode } set { typesetter.lineBreakMode = newValue } } var numberOfLineFragments: Int { - return typesetter.lineFragments.count + typesetter.lineFragments.count } var isFinishedTypesetting: Bool { - return typesetter.isFinishedTypesetting + typesetter.isFinishedTypesetting } private(set) var attributedString: NSMutableAttributedString? @@ -163,11 +163,11 @@ final class LineController { } func lineFragmentNode(containingCharacterAt location: Int) -> LineFragmentNode? { - return lineFragmentTree.node(containingLocation: location) + lineFragmentTree.node(containingLocation: location) } func lineFragmentNode(atIndex index: Int) -> LineFragmentNode { - return lineFragmentTree.node(atIndex: index) + lineFragmentTree.node(atIndex: index) } func setNeedsDisplayOnLineFragmentViews() { diff --git a/Sources/Runestone/TextView/LineController/LineControllerFactory.swift b/Sources/Runestone/TextView/LineController/LineControllerFactory.swift index 03a6c4967..a7111ef66 100644 --- a/Sources/Runestone/TextView/LineController/LineControllerFactory.swift +++ b/Sources/Runestone/TextView/LineController/LineControllerFactory.swift @@ -11,9 +11,9 @@ final class LineControllerFactory { } func makeLineController(for line: DocumentLineNode) -> LineController { - return LineController(line: line, - stringView: stringView, - invisibleCharacterConfiguration: invisibleCharacterConfiguration, - highlightService: highlightService) + LineController(line: line, + stringView: stringView, + invisibleCharacterConfiguration: invisibleCharacterConfiguration, + highlightService: highlightService) } } diff --git a/Sources/Runestone/TextView/LineController/LineControllerStorage.swift b/Sources/Runestone/TextView/LineController/LineControllerStorage.swift index ab9e68ea9..8e0a6090f 100644 --- a/Sources/Runestone/TextView/LineController/LineControllerStorage.swift +++ b/Sources/Runestone/TextView/LineController/LineControllerStorage.swift @@ -5,7 +5,7 @@ protocol LineControllerStorageDelegate: AnyObject { final class LineControllerStorage { weak var delegate: LineControllerStorageDelegate? subscript(_ lineID: DocumentLineNodeID) -> LineController? { - return lineControllers[lineID] + lineControllers[lineID] } var stringView: StringView { @@ -17,7 +17,7 @@ final class LineControllerStorage { } fileprivate var numberOfLineControllers: Int { - return lineControllers.count + lineControllers.count } private var lineControllers: [DocumentLineNodeID: LineController] = [:] diff --git a/Sources/Runestone/TextView/LineController/LineFragment.swift b/Sources/Runestone/TextView/LineController/LineFragment.swift index 22ebb6046..890cadebd 100644 --- a/Sources/Runestone/TextView/LineController/LineFragment.swift +++ b/Sources/Runestone/TextView/LineController/LineFragment.swift @@ -11,7 +11,7 @@ struct LineFragmentID: Identifiable, Hashable { extension LineFragmentID: CustomDebugStringConvertible { var debugDescription: String { - return id + id } } @@ -39,6 +39,6 @@ final class LineFragment { extension LineFragment: CustomDebugStringConvertible { var debugDescription: String { - return "[LineFragment id=\(id) descent=\(descent) baseSize=\(baseSize) scaledSize=\(scaledSize) yPosition=\(yPosition)]" + "[LineFragment id=\(id) descent=\(descent) baseSize=\(baseSize) scaledSize=\(scaledSize) yPosition=\(yPosition)]" } } diff --git a/Sources/Runestone/TextView/LineController/LineFragmentCharacterLocationQuery.swift b/Sources/Runestone/TextView/LineController/LineFragmentCharacterLocationQuery.swift index 6d88af580..66ede15d3 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentCharacterLocationQuery.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentCharacterLocationQuery.swift @@ -13,11 +13,11 @@ final class LineFragmentCharacterLocationQuery: RedBlackTreeSearchQuery { } func shouldTraverseLeftChildren(of node: RedBlackTreeNode) -> Bool { - return node.nodeTotalValue >= range.lowerBound + node.nodeTotalValue >= range.lowerBound } func shouldTraverseRightChildren(of node: RedBlackTreeNode) -> Bool { - return node.nodeTotalValue <= range.upperBound + node.nodeTotalValue <= range.upperBound } func shouldInclude(_ node: RedBlackTreeNode) -> Bool { diff --git a/Sources/Runestone/TextView/LineController/LineFragmentController.swift b/Sources/Runestone/TextView/LineController/LineFragmentController.swift index c94d5fe37..5a623b331 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentController.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentController.swift @@ -23,7 +23,7 @@ final class LineFragmentController { } var markedRange: NSRange? { get { - return renderer.markedRange + renderer.markedRange } set { if newValue != renderer.markedRange { @@ -34,7 +34,7 @@ final class LineFragmentController { } var markedTextBackgroundColor: UIColor { get { - return renderer.markedTextBackgroundColor + renderer.markedTextBackgroundColor } set { if newValue != renderer.markedTextBackgroundColor { @@ -45,7 +45,7 @@ final class LineFragmentController { } var markedTextBackgroundCornerRadius: CGFloat { get { - return renderer.markedTextBackgroundCornerRadius + renderer.markedTextBackgroundCornerRadius } set { if newValue != renderer.markedTextBackgroundCornerRadius { @@ -56,7 +56,7 @@ final class LineFragmentController { } var highlightedRangeFragments: [HighlightedRangeFragment] { get { - return renderer.highlightedRangeFragments + renderer.highlightedRangeFragments } set { if newValue != renderer.highlightedRangeFragments { @@ -78,6 +78,6 @@ final class LineFragmentController { // MARK: - LineFragmentRendererDelegate extension LineFragmentController: LineFragmentRendererDelegate { func string(in lineFragmentRenderer: LineFragmentRenderer) -> String? { - return delegate?.string(in: self) + delegate?.string(in: self) } } diff --git a/Sources/Runestone/TextView/LineController/LineFragmentFrameQuery.swift b/Sources/Runestone/TextView/LineController/LineFragmentFrameQuery.swift index d8690d79e..f97d4549c 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentFrameQuery.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentFrameQuery.swift @@ -12,11 +12,11 @@ final class LineFragmentFrameQuery: RedBlackTreeSearchQuery { } func shouldTraverseLeftChildren(of node: RedBlackTreeNode) -> Bool { - return node.data.totalLineFragmentHeight >= range.lowerBound + node.data.totalLineFragmentHeight >= range.lowerBound } func shouldTraverseRightChildren(of node: RedBlackTreeNode) -> Bool { - return node.data.totalLineFragmentHeight <= range.upperBound + node.data.totalLineFragmentHeight <= range.upperBound } func shouldInclude(_ node: RedBlackTreeNode) -> Bool { diff --git a/Sources/Runestone/TextView/LineController/LineFragmentNode.swift b/Sources/Runestone/TextView/LineController/LineFragmentNode.swift index bd6aefde2..76bb81244 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentNode.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentNode.swift @@ -8,7 +8,7 @@ struct LineFragmentNodeID: RedBlackTreeNodeID { final class LineFragmentNodeData { var lineFragment: LineFragment? var lineFragmentHeight: CGFloat { - return lineFragment?.scaledSize.height ?? 0 + lineFragment?.scaledSize.height ?? 0 } var totalLineFragmentHeight: CGFloat = 0 diff --git a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift index 6f0ca9bd9..8a32d46b6 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift @@ -20,7 +20,7 @@ final class LineFragmentRenderer { var highlightedRangeFragments: [HighlightedRangeFragment] = [] private var showInvisibleCharacters: Bool { - return invisibleCharacterConfiguration.showTabs + invisibleCharacterConfiguration.showTabs || invisibleCharacterConfiguration.showSpaces || invisibleCharacterConfiguration.showLineBreaks || invisibleCharacterConfiguration.showSoftLineBreaks @@ -154,6 +154,6 @@ private extension LineFragmentRenderer { } private func isLineBreak(_ string: String.Element) -> Bool { - return string == Symbol.Character.lineFeed || string == Symbol.Character.carriageReturn || string == Symbol.Character.carriageReturnLineFeed + string == Symbol.Character.lineFeed || string == Symbol.Character.carriageReturn || string == Symbol.Character.carriageReturnLineFeed } } diff --git a/Sources/Runestone/TextView/LineController/LineTypesetter.swift b/Sources/Runestone/TextView/LineController/LineTypesetter.swift index 38f12f9ce..f0af63938 100644 --- a/Sources/Runestone/TextView/LineController/LineTypesetter.swift +++ b/Sources/Runestone/TextView/LineController/LineTypesetter.swift @@ -45,10 +45,10 @@ final class LineTypesetter { } } var isFinishedTypesetting: Bool { - return startOffset >= stringLength + startOffset >= stringLength } var typesetLength: Int { - return startOffset + startOffset } private let lineID: String diff --git a/Sources/Runestone/TextView/PageGuide/PageGuideView.swift b/Sources/Runestone/TextView/PageGuide/PageGuideView.swift index a11e93ef3..2fd6d14d2 100644 --- a/Sources/Runestone/TextView/PageGuide/PageGuideView.swift +++ b/Sources/Runestone/TextView/PageGuide/PageGuideView.swift @@ -10,7 +10,7 @@ final class PageGuideView: UIView { } var hairlineColor: UIColor? { get { - return hairlineView.backgroundColor + hairlineView.backgroundColor } set { hairlineView.backgroundColor = newValue diff --git a/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift b/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift index 3856240e7..47ab89680 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/ParsedReplacementString.swift @@ -33,7 +33,7 @@ struct ParsedReplacementString: Equatable { let components: [Component] var containsPlaceholder: Bool { - return components.contains { component in + components.contains { component in switch component { case .text: return false diff --git a/Sources/Runestone/TextView/SearchAndReplace/SearchController.swift b/Sources/Runestone/TextView/SearchAndReplace/SearchController.swift index 9ae3310eb..f8679e86c 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/SearchController.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/SearchController.swift @@ -14,8 +14,8 @@ final class SearchController { } func search(for query: SearchQuery) -> [SearchResult] { - return search(for: query) { textCheckingResult in - return searchResult(in: textCheckingResult.range) + search(for: query) { textCheckingResult in + searchResult(in: textCheckingResult.range) } } @@ -34,8 +34,8 @@ final class SearchController { private extension SearchController { private func search(for query: SearchQuery, replacingWithPlainText replacementText: String) -> [SearchReplaceResult] { - return search(for: query) { textCheckingResult in - return searchReplaceResult(in: textCheckingResult.range, replacementText: replacementText) + search(for: query) { textCheckingResult in + searchReplaceResult(in: textCheckingResult.range, replacementText: replacementText) } } diff --git a/Sources/Runestone/TextView/SearchAndReplace/SearchQuery.swift b/Sources/Runestone/TextView/SearchAndReplace/SearchQuery.swift index 86231e164..250f7fc5f 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/SearchQuery.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/SearchQuery.swift @@ -46,7 +46,7 @@ public struct SearchQuery: Hashable, Equatable { } } private var escapedText: String { - return NSRegularExpression.escapedPattern(for: text) + NSRegularExpression.escapedPattern(for: text) } private var regularExpressionOptions: NSRegularExpression.Options { var options: NSRegularExpression.Options = [.anchorsMatchLines] diff --git a/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift b/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift index 6e77294fa..51e60c8b6 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/StringModifier.swift @@ -19,7 +19,7 @@ enum StringModifier { } } var string: String { - return "\\" + String(character) + "\\" + String(character) } private var terminatesStringModification: Bool { diff --git a/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift index 72495876a..7f26864c6 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift @@ -18,7 +18,7 @@ final class UITextSearchingHelper: NSObject { var findInteraction: UIFindInteraction? { get { guard let _findInteraction = _findInteraction else { - return nil + nil } guard let findInteraction = _findInteraction as? UIFindInteraction else { fatalError("Expected _findInteraction to be of type \(UIFindInteraction.self)") @@ -56,11 +56,11 @@ final class UITextSearchingHelper: NSObject { @available(iOS 16, *) extension UITextSearchingHelper: UITextSearching { var supportsTextReplacement: Bool { - return true + true } var selectedTextRange: UITextRange? { - return _textView.selectedTextRange + _textView.selectedTextRange } func compare(_ foundRange: UITextRange, toRange: UITextRange, document: AnyHashable??) -> ComparisonResult { @@ -171,7 +171,7 @@ private extension UITextSearchingHelper { extension UITextSearchingHelper: UIFindInteractionDelegate { @available(iOS 16, *) func findInteraction(_ interaction: UIFindInteraction, sessionFor view: UIView) -> UIFindSession? { - return UITextSearchingFindSession(searchableObject: self) + UITextSearchingFindSession(searchableObject: self) } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextInternalLanguageMode.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextInternalLanguageMode.swift index 302db1843..efa060740 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextInternalLanguageMode.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextInternalLanguageMode.swift @@ -8,37 +8,37 @@ final class PlainTextInternalLanguageMode: InternalLanguageMode { } func textDidChange(_ change: TextChange) -> LineChangeSet { - return LineChangeSet() + LineChangeSet() } func tokenType(at location: Int) -> String? { - return nil + nil } func createLineSyntaxHighlighter() -> LineSyntaxHighlighter { - return PlainTextSyntaxHighlighter() + PlainTextSyntaxHighlighter() } func highestSyntaxNode(at linePosition: LinePosition) -> SyntaxNode? { - return nil + nil } func syntaxNode(at linePosition: LinePosition) -> SyntaxNode? { - return nil + nil } func currentIndentLevel(of line: DocumentLineNode, using indentStrategy: IndentStrategy) -> Int { - return 0 + 0 } func strategyForInsertingLineBreak( from startLinePosition: LinePosition, to endLinePosition: LinePosition, using indentStrategy: IndentStrategy) -> InsertLineBreakIndentStrategy { - return InsertLineBreakIndentStrategy(indentLevel: 0, insertExtraLineBreak: false) + InsertLineBreakIndentStrategy(indentLevel: 0, insertExtraLineBreak: false) } func detectIndentStrategy() -> DetectedIndentStrategy { - return .unknown + .unknown } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextSyntaxHighlighter.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextSyntaxHighlighter.swift index 36231f7d4..e77967289 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextSyntaxHighlighter.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/PlainText/PlainTextSyntaxHighlighter.swift @@ -5,13 +5,13 @@ final class PlainTextSyntaxHighlighter: LineSyntaxHighlighter { var theme: Theme = DefaultTheme() var kern: CGFloat = 0 var canHighlight: Bool { - return false + false } func syntaxHighlight(_ input: LineSyntaxHighlighterInput) {} func syntaxHighlight(_ input: LineSyntaxHighlighterInput, completion: @escaping AsyncCallback) { - return completion(.success(())) + completion(.success(())) } func cancel() {} diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift index 39bf89a8a..33aaa4a96 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterInternalLanguageMode.swift @@ -8,7 +8,7 @@ protocol TreeSitterLanguageModeDelegate: AnyObject { final class TreeSitterInternalLanguageMode: InternalLanguageMode { weak var delegate: TreeSitterLanguageModeDelegate? var canHighlight: Bool { - return rootLanguageLayer.canHighlight + rootLanguageLayer.canHighlight } private let stringView: StringView @@ -72,11 +72,11 @@ final class TreeSitterInternalLanguageMode: InternalLanguageMode { } func captures(in range: ByteRange) -> [TreeSitterCapture] { - return rootLanguageLayer.captures(in: range) + rootLanguageLayer.captures(in: range) } func createLineSyntaxHighlighter() -> LineSyntaxHighlighter { - return TreeSitterSyntaxHighlighter(stringView: stringView, languageMode: self, operationQueue: operationQueue) + TreeSitterSyntaxHighlighter(stringView: stringView, languageMode: self, operationQueue: operationQueue) } func currentIndentLevel(of line: DocumentLineNode, using indentStrategy: IndentStrategy) -> Int { @@ -129,6 +129,6 @@ final class TreeSitterInternalLanguageMode: InternalLanguageMode { extension TreeSitterInternalLanguageMode: TreeSitterParserDelegate { func parser(_ parser: TreeSitterParser, bytesAt byteIndex: ByteCount) -> TreeSitterTextProviderResult? { - return delegate?.treeSitterLanguageMode(self, bytesAt: byteIndex) + delegate?.treeSitterLanguageMode(self, bytesAt: byteIndex) } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift index 9ac441769..3b84a5021 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterLanguageLayer.swift @@ -6,7 +6,7 @@ final class TreeSitterLanguageLayer { let language: TreeSitterInternalLanguage private(set) var tree: TreeSitterTree? var canHighlight: Bool { - return parser.language != nil && tree != nil + parser.language != nil && tree != nil } private let lineManager: LineManager @@ -253,7 +253,7 @@ extension TreeSitterLanguageLayer { extension TreeSitterLanguageLayer: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterLanguageLayer node=\(tree?.rootNode.debugDescription ?? "") childLanguageLayers=\(childLanguageLayers)]" + "[TreeSitterLanguageLayer node=\(tree?.rootNode.debugDescription ?? "") childLanguageLayers=\(childLanguageLayers)]" } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift index c6333b923..64cd6cdee 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift @@ -7,7 +7,7 @@ final class TreeSitterSyntaxHighlightToken { let font: UIFont? let fontTraits: FontTraits var isEmpty: Bool { - return range.length == 0 || (textColor == nil && font == nil && shadow == nil) + range.length == 0 || (textColor == nil && font == nil && shadow == nil) } init(range: NSRange, textColor: UIColor?, shadow: NSShadow?, font: UIFont?, fontTraits: FontTraits) { @@ -21,7 +21,7 @@ final class TreeSitterSyntaxHighlightToken { extension TreeSitterSyntaxHighlightToken: Equatable { static func == (lhs: TreeSitterSyntaxHighlightToken, rhs: TreeSitterSyntaxHighlightToken) -> Bool { - return lhs.range == rhs.range && lhs.textColor == rhs.textColor && lhs.font == rhs.font + lhs.range == rhs.range && lhs.textColor == rhs.textColor && lhs.font == rhs.font } } @@ -37,6 +37,6 @@ extension TreeSitterSyntaxHighlightToken { extension TreeSitterSyntaxHighlightToken: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterSyntaxHighlightToken: \(range.location) - \(range.length)]" + "[TreeSitterSyntaxHighlightToken: \(range.location) - \(range.length)]" } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift index 8885b9a0b..f0bc23781 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlighter.swift @@ -18,7 +18,7 @@ final class TreeSitterSyntaxHighlighter: LineSyntaxHighlighter { var theme: Theme = DefaultTheme() var kern: CGFloat = 0 var canHighlight: Bool { - return languageMode.canHighlight + languageMode.canHighlight } private let stringView: StringView diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/PlainText/PlainTextLanguageMode.swift b/Sources/Runestone/TextView/SyntaxHighlighting/PlainText/PlainTextLanguageMode.swift index 79049a7b8..099341eda 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/PlainText/PlainTextLanguageMode.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/PlainText/PlainTextLanguageMode.swift @@ -10,6 +10,6 @@ public final class PlainTextLanguageMode { extension PlainTextLanguageMode: LanguageMode { func makeInternalLanguageMode(stringView: StringView, lineManager: LineManager) -> InternalLanguageMode { - return PlainTextInternalLanguageMode() + PlainTextInternalLanguageMode() } } diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterIndentationScopes.swift b/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterIndentationScopes.swift index 8b5a7899b..161d61b47 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterIndentationScopes.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterIndentationScopes.swift @@ -61,7 +61,7 @@ public final class TreeSitterIndentationScopes { extension TreeSitterIndentationScopes: CustomDebugStringConvertible { public var debugDescription: String { - return "[TreeSitterIndentationScopes indent=\(indent)" + "[TreeSitterIndentationScopes indent=\(indent)" + " inheritIndent=\(inheritIndent)" + " outdent=\(outdent)" + " whitespaceDenotesBlocks=\(whitespaceDenotesBlocks)]" diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterLanguageMode.swift b/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterLanguageMode.swift index 328ae1615..546509005 100644 --- a/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterLanguageMode.swift +++ b/Sources/Runestone/TextView/SyntaxHighlighting/TreeSitter/TreeSitterLanguageMode.swift @@ -23,7 +23,7 @@ public final class TreeSitterLanguageMode { extension TreeSitterLanguageMode: LanguageMode { func makeInternalLanguageMode(stringView: StringView, lineManager: LineManager) -> InternalLanguageMode { - return TreeSitterInternalLanguageMode( + TreeSitterInternalLanguageMode( language: language.internalLanguage, languageProvider: languageProvider, stringView: stringView, diff --git a/Sources/Runestone/TextView/TextSelection/TextSelectionRect.swift b/Sources/Runestone/TextView/TextSelection/TextSelectionRect.swift index 4187fdef9..a7c717059 100644 --- a/Sources/Runestone/TextView/TextSelection/TextSelectionRect.swift +++ b/Sources/Runestone/TextView/TextSelection/TextSelectionRect.swift @@ -2,19 +2,19 @@ import UIKit final class TextSelectionRect: UITextSelectionRect { override var rect: CGRect { - return _rect + _rect } override var writingDirection: NSWritingDirection { - return _writingDirection + _writingDirection } override var containsStart: Bool { - return _containsStart + _containsStart } override var containsEnd: Bool { - return _containsEnd + _containsEnd } override var isVertical: Bool { - return _isVertical + _isVertical } private let _rect: CGRect diff --git a/Sources/Runestone/TreeSitter/TreeSitterCapture.swift b/Sources/Runestone/TreeSitter/TreeSitterCapture.swift index f35cd89d6..14e05909d 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterCapture.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterCapture.swift @@ -27,6 +27,6 @@ final class TreeSitterCapture { extension TreeSitterCapture: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterCapture byteRange=\(byteRange) name=\(name) properties=\(properties) textPredicates=\(textPredicates)]" + "[TreeSitterCapture byteRange=\(byteRange) name=\(name) properties=\(properties) textPredicates=\(textPredicates)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterInputEdit.swift b/Sources/Runestone/TreeSitter/TreeSitterInputEdit.swift index e22117405..7dbedd5c5 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterInputEdit.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterInputEdit.swift @@ -25,7 +25,7 @@ final class TreeSitterInputEdit { extension TreeSitterInputEdit: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterInputEdit startByte=\(startByte) oldEndByte=\(oldEndByte) newEndByte=\(newEndByte)" + "[TreeSitterInputEdit startByte=\(startByte) oldEndByte=\(oldEndByte) newEndByte=\(newEndByte)" + " startPoint=\(startPoint) oldEndPoint=\(oldEndPoint) newEndPoint=\(newEndPoint)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterNode.swift b/Sources/Runestone/TreeSitter/TreeSitterNode.swift index ec84964f4..e7a89865b 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterNode.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterNode.swift @@ -19,34 +19,34 @@ final class TreeSitterNode { } } var startByte: ByteCount { - return ByteCount(ts_node_start_byte(rawValue)) + ByteCount(ts_node_start_byte(rawValue)) } var endByte: ByteCount { - return ByteCount(ts_node_end_byte(rawValue)) + ByteCount(ts_node_end_byte(rawValue)) } var startPoint: TreeSitterTextPoint { - return TreeSitterTextPoint(ts_node_start_point(rawValue)) + TreeSitterTextPoint(ts_node_start_point(rawValue)) } var endPoint: TreeSitterTextPoint { - return TreeSitterTextPoint(ts_node_end_point(rawValue)) + TreeSitterTextPoint(ts_node_end_point(rawValue)) } var byteRange: ByteRange { - return ByteRange(from: startByte, to: endByte) + ByteRange(from: startByte, to: endByte) } var parent: TreeSitterNode? { - return getRelationship(using: ts_node_parent) + getRelationship(using: ts_node_parent) } var previousSibling: TreeSitterNode? { - return getRelationship(using: ts_node_prev_sibling) + getRelationship(using: ts_node_prev_sibling) } var nextSibling: TreeSitterNode? { - return getRelationship(using: ts_node_next_sibling) + getRelationship(using: ts_node_next_sibling) } var textRange: TreeSitterTextRange { - return TreeSitterTextRange(startPoint: startPoint, endPoint: endPoint, startByte: startByte, endByte: endByte) + TreeSitterTextRange(startPoint: startPoint, endPoint: endPoint, startByte: startByte, endByte: endByte) } var childCount: Int { - return Int(ts_node_child_count(rawValue)) + Int(ts_node_child_count(rawValue)) } init(node: TSNode) { @@ -81,7 +81,7 @@ private extension TreeSitterNode { extension TreeSitterNode: Hashable { static func == (lhs: TreeSitterNode, rhs: TreeSitterNode) -> Bool { - return lhs.rawValue.id == rhs.rawValue.id + lhs.rawValue.id == rhs.rawValue.id } func hash(into hasher: inout Hasher) { @@ -91,6 +91,6 @@ extension TreeSitterNode: Hashable { extension TreeSitterNode: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterNode startByte=\(startByte) endByte=\(endByte) startPoint=\(startPoint) endPoint=\(endPoint)]" + "[TreeSitterNode startByte=\(startByte) endByte=\(endByte) startPoint=\(startPoint) endPoint=\(endPoint)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterParser.swift b/Sources/Runestone/TreeSitter/TreeSitterParser.swift index 9d6c62dcd..d7073445e 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterParser.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterParser.swift @@ -14,7 +14,7 @@ final class TreeSitterParser { } } var canParse: Bool { - return language != nil + language != nil } private var pointer: OpaquePointer @@ -67,7 +67,7 @@ final class TreeSitterParser { func setIncludedRanges(_ ranges: [TreeSitterTextRange]) -> Bool { let rawRanges = ranges.map { $0.rawValue } return rawRanges.withUnsafeBufferPointer { rangesPointer in - return ts_parser_set_included_ranges(pointer, rangesPointer.baseAddress, UInt32(rawRanges.count)) + ts_parser_set_included_ranges(pointer, rangesPointer.baseAddress, UInt32(rawRanges.count)) } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterPredicate.swift b/Sources/Runestone/TreeSitter/TreeSitterPredicate.swift index 973f28987..eebb8eee3 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterPredicate.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterPredicate.swift @@ -17,7 +17,7 @@ final class TreeSitterPredicate { extension TreeSitterPredicate: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterPredicate name=\(name) steps=\(steps)]" + "[TreeSitterPredicate name=\(name) steps=\(steps)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterQuery.swift b/Sources/Runestone/TreeSitter/TreeSitterQuery.swift index 3ac34caf8..1619d2575 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterQuery.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterQuery.swift @@ -14,7 +14,7 @@ final class TreeSitterQuery { private let language: UnsafePointer private var patternCount: UInt32 { - return ts_query_pattern_count(pointer) + ts_query_pattern_count(pointer) } init(source: String, language: UnsafePointer) throws { diff --git a/Sources/Runestone/TreeSitter/TreeSitterQueryCursor.swift b/Sources/Runestone/TreeSitter/TreeSitterQueryCursor.swift index ce29f5f84..8c1c51201 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterQueryCursor.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterQueryCursor.swift @@ -47,7 +47,7 @@ final class TreeSitterQueryCursor { let match = TreeSitterQueryMatch(captures: captures) let evaluator = TreeSitterTextPredicatesEvaluator(match: match, stringView: stringView) result += captures.filter { capture in - return capture.byteRange.length > 0 && evaluator.evaluatePredicates(in: capture) + capture.byteRange.length > 0 && evaluator.evaluatePredicates(in: capture) } } return result diff --git a/Sources/Runestone/TreeSitter/TreeSitterQueryMatch.swift b/Sources/Runestone/TreeSitter/TreeSitterQueryMatch.swift index 1dcd651bf..37700a5f2 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterQueryMatch.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterQueryMatch.swift @@ -8,12 +8,12 @@ final class TreeSitterQueryMatch { } func capture(forIndex index: UInt32) -> TreeSitterCapture? { - return captures.first { $0.index == index } + captures.first { $0.index == index } } } extension TreeSitterQueryMatch: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterQueryMatch captures=\(captures.count)]" + "[TreeSitterQueryMatch captures=\(captures.count)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterTextPoint.swift b/Sources/Runestone/TreeSitter/TreeSitterTextPoint.swift index 83fa9fdf2..872069254 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterTextPoint.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterTextPoint.swift @@ -2,10 +2,10 @@ import TreeSitter final class TreeSitterTextPoint { var row: UInt32 { - return rawValue.row + rawValue.row } var column: UInt32 { - return rawValue.column + rawValue.column } let rawValue: TSPoint @@ -21,6 +21,6 @@ final class TreeSitterTextPoint { extension TreeSitterTextPoint: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTextPoint row=\(row) column=\(column)]" + "[TreeSitterTextPoint row=\(row) column=\(column)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterTextPredicate.swift b/Sources/Runestone/TreeSitter/TreeSitterTextPredicate.swift index 6801be02c..2ecbaddb9 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterTextPredicate.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterTextPredicate.swift @@ -53,13 +53,13 @@ enum TreeSitterTextPredicate { extension TreeSitterTextPredicate.CaptureEqualsStringParameters: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTextPredicate.CaptureEqualsStringParameters captureIndex=\(captureIndex) string=\(string) isPositive=\(isPositive)]" + "[TreeSitterTextPredicate.CaptureEqualsStringParameters captureIndex=\(captureIndex) string=\(string) isPositive=\(isPositive)]" } } extension TreeSitterTextPredicate.CaptureEqualsCaptureParameters: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTextPredicate.CaptureEqualsCaptureParameters lhsCaptureIndex=\(lhsCaptureIndex)" + "[TreeSitterTextPredicate.CaptureEqualsCaptureParameters lhsCaptureIndex=\(lhsCaptureIndex)" + " rhsCaptureIndex=\(rhsCaptureIndex)" + " isPositive=\(isPositive)]" } @@ -67,6 +67,6 @@ extension TreeSitterTextPredicate.CaptureEqualsCaptureParameters: CustomDebugStr extension TreeSitterTextPredicate.CaptureMatchesPatternParameters: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTextPredicate.CaptureMatchesPatternParameters captureIndex=\(captureIndex) pattern=\(pattern) isPositive=\(isPositive)]" + "[TreeSitterTextPredicate.CaptureMatchesPatternParameters captureIndex=\(captureIndex) pattern=\(pattern) isPositive=\(isPositive)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterTextRange.swift b/Sources/Runestone/TreeSitter/TreeSitterTextRange.swift index df952dcc1..ade82fc83 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterTextRange.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterTextRange.swift @@ -3,16 +3,16 @@ import TreeSitter final class TreeSitterTextRange { let rawValue: TSRange var startPoint: TreeSitterTextPoint { - return TreeSitterTextPoint(row: rawValue.start_point.row, column: rawValue.start_point.column) + TreeSitterTextPoint(row: rawValue.start_point.row, column: rawValue.start_point.column) } var endPoint: TreeSitterTextPoint { - return TreeSitterTextPoint(row: rawValue.end_point.row, column: rawValue.end_point.column) + TreeSitterTextPoint(row: rawValue.end_point.row, column: rawValue.end_point.column) } var startByte: ByteCount { - return ByteCount(rawValue.start_byte) + ByteCount(rawValue.start_byte) } var endByte: ByteCount { - return ByteCount(rawValue.end_byte) + ByteCount(rawValue.end_byte) } init(startPoint: TreeSitterTextPoint, endPoint: TreeSitterTextPoint, startByte: ByteCount, endByte: ByteCount) { @@ -26,6 +26,6 @@ final class TreeSitterTextRange { extension TreeSitterTextRange: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTextRange startByte=\(startByte) endByte=\(endByte) startPoint=\(startPoint) endPoint=\(endPoint)]" + "[TreeSitterTextRange startByte=\(startByte) endByte=\(endByte) startPoint=\(startPoint) endPoint=\(endPoint)]" } } diff --git a/Sources/Runestone/TreeSitter/TreeSitterTree.swift b/Sources/Runestone/TreeSitter/TreeSitterTree.swift index dd6cfaa57..8de366f16 100644 --- a/Sources/Runestone/TreeSitter/TreeSitterTree.swift +++ b/Sources/Runestone/TreeSitter/TreeSitterTree.swift @@ -3,7 +3,7 @@ import TreeSitter final class TreeSitterTree { let pointer: OpaquePointer var rootNode: TreeSitterNode { - return TreeSitterNode(node: ts_tree_root_node(pointer)) + TreeSitterNode(node: ts_tree_root_node(pointer)) } init(_ tree: OpaquePointer) { @@ -35,6 +35,6 @@ final class TreeSitterTree { extension TreeSitterTree: CustomDebugStringConvertible { var debugDescription: String { - return "[TreeSitterTree rootNode=\(rootNode)]" + "[TreeSitterTree rootNode=\(rootNode)]" } } diff --git a/Tests/RunestoneTests/Mock/MockTreeSitterParserDelegate.swift b/Tests/RunestoneTests/Mock/MockTreeSitterParserDelegate.swift index 248c9f3f5..6edde3808 100644 --- a/Tests/RunestoneTests/Mock/MockTreeSitterParserDelegate.swift +++ b/Tests/RunestoneTests/Mock/MockTreeSitterParserDelegate.swift @@ -4,7 +4,7 @@ import Foundation final class MockTreeSitterParserDelegate: TreeSitterParserDelegate { var string: NSString { get { - return stringView.string + stringView.string } set { stringView.string = newValue diff --git a/Tests/RunestoneTests/TextInputStringTokenizerTests.swift b/Tests/RunestoneTests/TextInputStringTokenizerTests.swift index c4276959e..753317ded 100644 --- a/Tests/RunestoneTests/TextInputStringTokenizerTests.swift +++ b/Tests/RunestoneTests/TextInputStringTokenizerTests.swift @@ -299,7 +299,7 @@ extension TextInputStringTokenizerTests: LineControllerStorageDelegate { extension TextInputStringTokenizerTests: LineControllerDelegate { func lineSyntaxHighlighter(for lineController: LineController) -> LineSyntaxHighlighter? { - return PlainTextSyntaxHighlighter() + PlainTextSyntaxHighlighter() } func lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ lineController: LineController) {} diff --git a/Tests/RunestoneTests/XCTestManifests.swift b/Tests/RunestoneTests/XCTestManifests.swift index b8387f40c..b3cf78dba 100644 --- a/Tests/RunestoneTests/XCTestManifests.swift +++ b/Tests/RunestoneTests/XCTestManifests.swift @@ -2,7 +2,7 @@ import XCTest #if !canImport(ObjectiveC) public func allTests() -> [XCTestCaseEntry] { - return [ + [ testCase(RunestoneTests.allTests) ] } diff --git a/UITests/Host/Sources/AppDelegate.swift b/UITests/Host/Sources/AppDelegate.swift index 6c8b84f49..34afbfc18 100644 --- a/UITests/Host/Sources/AppDelegate.swift +++ b/UITests/Host/Sources/AppDelegate.swift @@ -4,6 +4,6 @@ import UIKit final class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - return true + true } } diff --git a/UITests/Host/Sources/ProcessInfo+Helpers.swift b/UITests/Host/Sources/ProcessInfo+Helpers.swift index 766f636cb..400b0fbeb 100644 --- a/UITests/Host/Sources/ProcessInfo+Helpers.swift +++ b/UITests/Host/Sources/ProcessInfo+Helpers.swift @@ -2,6 +2,6 @@ import Foundation extension ProcessInfo { var useCRLFLineEndings: Bool { - return environment["crlfLineEndings"] != nil + environment["crlfLineEndings"] != nil } } diff --git a/UITests/HostUITests/XCUIApplication+Helpers.swift b/UITests/HostUITests/XCUIApplication+Helpers.swift index fff066454..a95acd23f 100644 --- a/UITests/HostUITests/XCUIApplication+Helpers.swift +++ b/UITests/HostUITests/XCUIApplication+Helpers.swift @@ -7,7 +7,7 @@ private enum EnvironmentKey { extension XCUIApplication { var textView: XCUIElement? { - return scrollViews.children(matching: .textView).element + scrollViews.children(matching: .textView).element } func tap(at point: CGPoint) { diff --git a/UITests/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift b/UITests/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift index 988a5730b..c2881dde2 100644 --- a/UITests/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift +++ b/UITests/Languages/Sources/RunestoneJavaScriptLanguage/IndentationScopes.swift @@ -2,7 +2,7 @@ import Runestone public extension TreeSitterIndentationScopes { static var javaScript: TreeSitterIndentationScopes { - return TreeSitterIndentationScopes( + TreeSitterIndentationScopes( indent: [ "array", "object", diff --git a/UITests/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift b/UITests/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift index 8142ac143..f757cf95a 100644 --- a/UITests/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift +++ b/UITests/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift @@ -17,6 +17,6 @@ public extension TreeSitterLanguage { private extension TreeSitterLanguage { static func queryFileURL(forQueryNamed queryName: String) -> URL { - return Bundle.module.url(forResource: "queries/" + queryName, withExtension: "scm")! + Bundle.module.url(forResource: "queries/" + queryName, withExtension: "scm")! } } From a432b59ea8d62a025c6f1121d764b29b13811e1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 08:08:52 +0100 Subject: [PATCH 123/232] Fixes compile issue --- .../TextView/SearchAndReplace/UITextSearchingHelper.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift index 7f26864c6..954b617c1 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift @@ -18,7 +18,8 @@ final class UITextSearchingHelper: NSObject { var findInteraction: UIFindInteraction? { get { guard let _findInteraction = _findInteraction else { - nil + // swiftlint:disable:next implicit_return + return nil } guard let findInteraction = _findInteraction as? UIFindInteraction else { fatalError("Expected _findInteraction to be of type \(UIFindInteraction.self)") From 75d655b6b4018913f02b08e2e53e59f868d17541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 08:11:28 +0100 Subject: [PATCH 124/232] Fixes SwiftLint warning --- .../TextView/SearchAndReplace/UITextSearchingHelper.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift index 954b617c1..3fa1cfad2 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift @@ -17,14 +17,15 @@ final class UITextSearchingHelper: NSObject { @available(iOS 16, *) var findInteraction: UIFindInteraction? { get { + // swiftlint:disable implicit_return guard let _findInteraction = _findInteraction else { - // swiftlint:disable:next implicit_return return nil } guard let findInteraction = _findInteraction as? UIFindInteraction else { fatalError("Expected _findInteraction to be of type \(UIFindInteraction.self)") } return findInteraction + // swiftlint:enable implicit_return } set { _findInteraction = newValue From b66770b7eb47b013b3fd3c8e07067187a5c3658f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 09:58:18 +0100 Subject: [PATCH 125/232] Fixes incorrect path to query --- .../RunestoneJavaScriptLanguage/TreeSitterLanguage.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift b/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift index f757cf95a..c3a47440b 100644 --- a/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift +++ b/Example/Languages/Sources/RunestoneJavaScriptLanguage/TreeSitterLanguage.swift @@ -17,6 +17,6 @@ public extension TreeSitterLanguage { private extension TreeSitterLanguage { static func queryFileURL(forQueryNamed queryName: String) -> URL { - Bundle.module.url(forResource: "queries/" + queryName, withExtension: "scm")! + Bundle.module.url(forResource: queryName, withExtension: "scm")! } } From dbc67fe7dfb9ccaa331125a980eb16d7381270a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 10:07:21 +0100 Subject: [PATCH 126/232] Removes superfluous disable command --- .../TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift index 807d707ce..b14205fb4 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift @@ -17,7 +17,6 @@ final class UITextSearchingHelper: NSObject { @available(iOS 16, *) var findInteraction: UIFindInteraction? { get { - // swiftlint:disable implicit_return guard let _findInteraction = _findInteraction else { return nil } @@ -25,7 +24,6 @@ final class UITextSearchingHelper: NSObject { fatalError("Expected _findInteraction to be of type \(UIFindInteraction.self)") } return findInteraction - // swiftlint:enable implicit_return } set { _findInteraction = newValue From 9d1a73c73193568aaac5696dbafdf4dbce4ebac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 6 Feb 2023 10:07:27 +0100 Subject: [PATCH 127/232] Fixes commands not received --- .../TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift index 645982190..d9f7abd9f 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift @@ -2,10 +2,12 @@ import AppKit extension TextView: NSTextInputClient { + // swiftlint:disable:next prohibited_super_call override public func doCommand(by selector: Selector) { #if DEBUG print(NSStringFromSelector(selector)) #endif + super.doCommand(by: selector) } public func insertText(_ string: Any, replacementRange: NSRange) { From 2640535e4033e2488249c36ecffaa65786ef26c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 8 Feb 2023 09:18:26 +0100 Subject: [PATCH 128/232] Enables line wrapping --- Example/MacExample/MainViewController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index d017eb7d8..495f398fe 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -22,7 +22,7 @@ final class MainViewController: NSViewController { this.lineSelectionDisplayType = .line this.gutterLeadingPadding = 4 this.gutterTrailingPadding = 4 - this.isLineWrappingEnabled = false + this.isLineWrappingEnabled = true this.indentStrategy = .space(length: 2) this.characterPairs = [ BasicCharacterPair(leading: "(", trailing: ")"), From 19da1bd6015292175d4b06d194130954ac937457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 8 Feb 2023 09:18:31 +0100 Subject: [PATCH 129/232] Adds default etxt --- Example/MacExample/MainViewController.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Example/MacExample/MainViewController.swift b/Example/MacExample/MainViewController.swift index 495f398fe..7a65e53ac 100644 --- a/Example/MacExample/MainViewController.swift +++ b/Example/MacExample/MainViewController.swift @@ -43,7 +43,20 @@ final class MainViewController: NSViewController { view.appearance = NSAppearance(named: .vibrantDark) setupTextView() applyTheme(theme) - let state = TextViewState(text: "", theme: theme, language: .javaScript) + // swiftlint:disable line_length + let text = """ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam ante ex, imperdiet in placerat eu, commodo ac dui. Fusce tincidunt facilisis eros condimentum varius. Ut tellus est, luctus pulvinar rutrum ac, semper at eros. Vestibulum et molestie dui. Nulla sagittis ipsum a dolor consectetur, ut ultrices turpis egestas. Quisque eleifend feugiat massa eget egestas. Donec sed ipsum sed lectus sodales sagittis at sit amet ipsum. Ut facilisis, augue vitae feugiat auctor, lacus metus feugiat augue, quis dictum quam ipsum nec felis. Donec nec orci justo. Pellentesque in est eu dui semper pulvinar. Donec at porta augue, a facilisis magna. + +Etiam lacinia et erat et luctus. Phasellus sit amet semper nisi. In nec nulla sit amet est elementum consequat eget sed magna. Maecenas tincidunt augue nec diam egestas dapibus. Cras porta vulputate ex ac fringilla. Proin rhoncus turpis sed hendrerit laoreet. Duis faucibus leo non posuere vulputate. Cras blandit dolor nibh, sit amet luctus massa commodo eget. Praesent a tempus leo, vel pretium urna. Quisque et sollicitudin neque. Morbi pellentesque felis pretium lectus molestie egestas. Suspendisse efficitur odio ac metus vehicula, eget cursus elit fermentum. Nunc maximus lectus eu erat volutpat iaculis. In elementum, risus nec commodo sollicitudin, diam est lobortis justo, vitae consequat erat dolor eu tellus. Pellentesque varius diam at urna eleifend maximus. + +Sed sagittis lectus id turpis bibendum, nec iaculis libero malesuada. Etiam nec ipsum vel tellus vestibulum sollicitudin ac ac nunc. Mauris non est vel sapien condimentum feugiat vel ullamcorper arcu. Duis a ligula quis justo ultrices feugiat at vel urna. Praesent erat turpis, convallis a feugiat ac, dictum a justo. Suspendisse venenatis tincidunt massa, nec mollis diam pretium lacinia. Cras non erat ut mauris iaculis lacinia. Cras accumsan purus vitae metus semper, non commodo arcu ullamcorper. Pellentesque porttitor lobortis ipsum, porta accumsan enim viverra ut. Duis ut tortor eget libero vulputate porttitor. Nulla bibendum libero tellus, sed mattis turpis feugiat non. + +Nunc lacus augue, tempus eu metus non, venenatis blandit massa. Donec consectetur cursus nibh eget iaculis. Pellentesque vel sem non tellus elementum rhoncus tempus quis est. In in neque sed ligula fermentum faucibus egestas in mauris. Vivamus id nunc non enim iaculis venenatis at vitae orci. Cras nec lacus nec nulla cursus rutrum. Donec vitae dui eget tellus tincidunt pharetra pulvinar id lacus. + +Sed et metus imperdiet, viverra lectus at, convallis justo. Suspendisse quis massa sodales, blandit ante vitae, mattis diam. Suspendisse potenti. Sed non odio aliquet, viverra purus quis, rhoncus lectus. Integer dignissim scelerisque lectus ut sagittis. Nunc ac nunc elit. Donec ligula nunc, egestas sed purus sed, ultrices dignissim eros. Ut accumsan porta velit, nec condimentum eros pellentesque et. Nunc ut ante eu turpis consectetur euismod sit amet quis urna. Duis nibh elit, dapibus vitae luctus in, placerat a mauris. Curabitur tincidunt venenatis nisl vitae euismod. Sed tristique sapien purus, sit amet auctor urna sodales a. +""" + // swiftlint:enable line_length + let state = TextViewState(text: text, theme: theme, language: .javaScript) textView.setState(state) } From 519f9bd119b74f7bcbab839c4c3ad80ae802097d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 8 Feb 2023 09:18:41 +0100 Subject: [PATCH 130/232] Makes LineSelectionView available on macOS only --- .../TextView/Core/Mac/LineSelectionView.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/LineSelectionView.swift b/Sources/Runestone/TextView/Core/Mac/LineSelectionView.swift index aef9491da..c6b61f6c4 100644 --- a/Sources/Runestone/TextView/Core/Mac/LineSelectionView.swift +++ b/Sources/Runestone/TextView/Core/Mac/LineSelectionView.swift @@ -1,3 +1,9 @@ -import Foundation +#if os(macOS) +import AppKit -final class LineSelectionView: MultiPlatformView, ReusableView {} +final class LineSelectionView: NSView, ReusableView { + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} +#endif From 8660d0ba377964d6368ba120df85516d6425f144 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Wed, 8 Feb 2023 09:19:18 +0100 Subject: [PATCH 131/232] WIP selection on the Mac --- .../GoToLineSelection.swift | 0 .../TextView/Core/LineNavigationService.swift | 121 ------------- .../TextView/Core/Mac/SelectionService.swift | 45 ----- .../Mac/TextView_Mac+NSTextInputClient.swift | 7 +- .../TextView/Core/Mac/TextView_Mac.swift | 43 ++++- .../TextView/Core/NavigationService.swift | 160 ------------------ .../TextView/Core/TextDirection.swift | 4 - .../{Navigation => Core}/TextLocation.swift | 0 .../TextViewController+Navigation.swift | 87 +++++----- .../TextViewController+Selection.swift | 40 +++-- .../TextViewController.swift | 10 +- .../Core/iOS/TextInputStringTokenizer.swift | 16 +- .../Core/iOS/TextView_iOS+UITextInput.swift | 8 +- .../CharacterNavigationLocationFactory.swift | 35 ++++ ...iderateLineNavigationLocationFactory.swift | 83 +++++++++ .../LineNavigationLocationFactory.swift | 114 +++++++++++++ .../WordNavigationLocationFactory.swift | 19 +++ .../Navigation/NavigationService.swift | 54 ++++++ .../Navigation/SelectionService.swift | 102 +++++++++++ .../StringTokenizer.swift | 104 ++++-------- .../{Core => Navigation}/TextBoundary.swift | 1 + .../TextView/Navigation/TextDirection.swift | 13 ++ .../TextGranularity.swift | 0 23 files changed, 582 insertions(+), 484 deletions(-) rename Sources/Runestone/TextView/{Navigation => Core}/GoToLineSelection.swift (100%) delete mode 100644 Sources/Runestone/TextView/Core/LineNavigationService.swift delete mode 100644 Sources/Runestone/TextView/Core/Mac/SelectionService.swift delete mode 100644 Sources/Runestone/TextView/Core/NavigationService.swift delete mode 100644 Sources/Runestone/TextView/Core/TextDirection.swift rename Sources/Runestone/TextView/{Navigation => Core}/TextLocation.swift (100%) create mode 100644 Sources/Runestone/TextView/Navigation/NavigationLocationFactories/CharacterNavigationLocationFactory.swift create mode 100644 Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift create mode 100644 Sources/Runestone/TextView/Navigation/NavigationLocationFactories/LineNavigationLocationFactory.swift create mode 100644 Sources/Runestone/TextView/Navigation/NavigationLocationFactories/WordNavigationLocationFactory.swift create mode 100644 Sources/Runestone/TextView/Navigation/NavigationService.swift create mode 100644 Sources/Runestone/TextView/Navigation/SelectionService.swift rename Sources/Runestone/TextView/{Core => Navigation}/StringTokenizer.swift (76%) rename Sources/Runestone/TextView/{Core => Navigation}/TextBoundary.swift (83%) create mode 100644 Sources/Runestone/TextView/Navigation/TextDirection.swift rename Sources/Runestone/TextView/{Core => Navigation}/TextGranularity.swift (100%) diff --git a/Sources/Runestone/TextView/Navigation/GoToLineSelection.swift b/Sources/Runestone/TextView/Core/GoToLineSelection.swift similarity index 100% rename from Sources/Runestone/TextView/Navigation/GoToLineSelection.swift rename to Sources/Runestone/TextView/Core/GoToLineSelection.swift diff --git a/Sources/Runestone/TextView/Core/LineNavigationService.swift b/Sources/Runestone/TextView/Core/LineNavigationService.swift deleted file mode 100644 index 66a7c0901..000000000 --- a/Sources/Runestone/TextView/Core/LineNavigationService.swift +++ /dev/null @@ -1,121 +0,0 @@ -import Foundation - -final class LineNavigationService { - var lineManager: LineManager - var lineControllerStorage: LineControllerStorage - - init(lineManager: LineManager, lineControllerStorage: LineControllerStorage) { - self.lineManager = lineManager - self.lineControllerStorage = lineControllerStorage - } - - func location(movingFrom sourceLocation: Int, byOffset offset: Int) -> Int { - guard let line = lineManager.line(containingCharacterAt: sourceLocation) else { - return sourceLocation - } - guard let lineController = lineControllerStorage[line.id] else { - return sourceLocation - } - let lineLocalLocation = max(min(sourceLocation - line.location, line.data.totalLength), 0) - guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { - return sourceLocation - } - let lineFragmentLocalLocation = lineLocalLocation - lineFragmentNode.location - return locationForMoving( - lineOffset: offset, - fromLocation: lineFragmentLocalLocation, - inLineFragmentAt: lineFragmentNode.index, - of: line - ) - } -} - -private extension LineNavigationService { - 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 { - return locationForMovingDownwards( - lineOffset: lineOffset, - fromLocation: location, - inLineFragmentAt: lineFragmentIndex, - of: line - ) - } else { - // lineOffset is 0 so we should not change the 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 - let lineMaximumLocation = lineLocation + line.data.length - let maximumLocation = min(lineFragmentMaximumLocation, lineMaximumLocation) - return min(preferredLocation, maximumLocation) - } - } - - 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 { - return locationForMoving(lineOffset: 0, fromLocation: location, inLineFragmentAt: lineFragmentIndex - takeLineCount, of: line) - } - let lineIndex = line.index - guard lineIndex > 0 else { - // We've reached the beginning of the document so we move to the first character. - return 0 - } - 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 - ) - } - - 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 - guard remainingLineOffset > 0 else { - return locationForMoving(lineOffset: 0, fromLocation: location, inLineFragmentAt: lineFragmentIndex + takeLineCount, of: line) - } - let lineIndex = line.index - guard lineIndex < lineManager.lineCount - 1 else { - // We've reached the end of the document so we move to the last character. - return line.location + line.data.totalLength - } - 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 { - lineControllerStorage.getOrCreateLineController(for: line).numberOfLineFragments - } -} diff --git a/Sources/Runestone/TextView/Core/Mac/SelectionService.swift b/Sources/Runestone/TextView/Core/Mac/SelectionService.swift deleted file mode 100644 index f21a66244..000000000 --- a/Sources/Runestone/TextView/Core/Mac/SelectionService.swift +++ /dev/null @@ -1,45 +0,0 @@ -#if os(macOS) -import Foundation - -final class SelectionService { - private let navigationService: NavigationService - private var previouslySelectedRange: NSRange? - - init(navigationService: NavigationService) { - self.navigationService = navigationService - } - - func range(movingFrom currentlySelectedRange: NSRange, by granularity: TextGranularity, inDirection direction: TextDirection) -> NSRange { - let selectedRange = previouslySelectedRange ?? currentlySelectedRange - let newSelectedRange = move(selectedRange, by: granularity, inDirection: direction) - previouslySelectedRange = newSelectedRange - return newSelectedRange - } - - func range(movingFrom currentlySelectedRange: NSRange, toBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> NSRange { - let selectedRange = previouslySelectedRange ?? currentlySelectedRange - let newSelectedRange = move(selectedRange, toBoundary: boundary, inDirection: direction) - previouslySelectedRange = newSelectedRange - return newSelectedRange - } - - func resetPreviouslySelectedRange() { - previouslySelectedRange = nil - } -} - -private extension SelectionService { - private func move(_ range: NSRange, by granularity: TextGranularity, inDirection directon: TextDirection) -> NSRange { - let offset = directon == .forward ? 1 : -1 - let newUpperBound = navigationService.location(movingFrom: range.upperBound, by: offset, granularity: granularity) - let lengthDiff = newUpperBound - range.upperBound - return NSRange(location: range.location, length: range.length + lengthDiff) - } - - private func move(_ range: NSRange, toBoundary boundary: TextBoundary, inDirection directon: TextDirection) -> NSRange { - let newUpperBound = navigationService.location(movingFrom: range.upperBound, toBoundary: boundary, inDirection: directon) - let lengthDiff = newUpperBound - range.upperBound - return NSRange(location: range.location, length: range.length + lengthDiff) - } -} -#endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift index d9f7abd9f..913d06ab0 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift @@ -4,9 +4,9 @@ import AppKit extension TextView: NSTextInputClient { // swiftlint:disable:next prohibited_super_call override public func doCommand(by selector: Selector) { - #if DEBUG - print(NSStringFromSelector(selector)) - #endif +// #if DEBUG +// print(NSStringFromSelector(selector)) +// #endif super.doCommand(by: selector) } @@ -14,7 +14,6 @@ extension TextView: NSTextInputClient { guard let string = string as? String else { return } - textViewController.selectionService.resetPreviouslySelectedRange() let range = replacementRange.location == NSNotFound ? textViewController.rangeForInsertingText : replacementRange if textViewController.shouldChangeText(in: range, replacementText: string) { textViewController.replaceText(in: range, with: string) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index f47454ce2..f3e6ebee6 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -517,6 +517,33 @@ open class TextView: NSView { } } + override public func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + if let location = locationClosestToPoint(in: event) { + textViewController.move(to: location) + textViewController.startDraggingSelection(from: location) + } + } + + override public func mouseDragged(with event: NSEvent) { + super.mouseDragged(with: event) + if let location = locationClosestToPoint(in: event) { + textViewController.extendDraggedSelection(to: location) + } + } + + override public func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + if let location = locationClosestToPoint(in: event) { + textViewController.extendDraggedSelection(to: location) + } + } + + open override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: .iBeam) + } + /// Sets the current _state_ of the editor. The state contains the text to be displayed by the editor and /// various additional information about the text that the editor needs to show the text. /// @@ -538,7 +565,6 @@ public extension TextView { guard var selectedRange = textViewController.markedRange ?? textViewController.selectedRange?.nonNegativeLength else { return } - textViewController.selectionService.resetPreviouslySelectedRange() if selectedRange.length == 0 { selectedRange.location -= 1 selectedRange.length = 1 @@ -704,11 +730,6 @@ public extension TextView { textViewController.moveToEndOfDocumentAndModifySelection() } - override func mouseDown(with event: NSEvent) { - let point = scrollContentView.convert(event.locationInWindow, from: nil) - textViewController.moveToLocation(closestTo: point) - } - /// Copy the selected text. /// /// - Parameter sender: The object calling this method. @@ -726,7 +747,6 @@ public extension TextView { @objc func paste(_ sender: Any?) { let selectedRange = selectedRange() if let string = NSPasteboard.general.string(forType: .string) { - print(string) let preparedText = textViewController.prepareTextForInsertion(string) textViewController.replaceText(in: selectedRange, with: preparedText) } @@ -873,6 +893,15 @@ private extension TextView { } } +// MARK: - Location +private extension TextView { + private func locationClosestToPoint(in event: NSEvent) -> Int? { + let point = scrollContentView.convert(event.locationInWindow, from: nil) + let adjustedPoint = CGPoint(x: point.x - gutterWidth - textContainerInset.left, y: point.y) + return textViewController.layoutManager.closestIndex(to: adjustedPoint) + } +} + // MARK: - TextViewControllerDelegate extension TextView: TextViewControllerDelegate { func textViewControllerDidChangeText(_ textViewController: TextViewController) { diff --git a/Sources/Runestone/TextView/Core/NavigationService.swift b/Sources/Runestone/TextView/Core/NavigationService.swift deleted file mode 100644 index 9644258ce..000000000 --- a/Sources/Runestone/TextView/Core/NavigationService.swift +++ /dev/null @@ -1,160 +0,0 @@ -import Foundation - -final class NavigationService { - var stringView: StringView { - didSet { - if stringView !== oldValue { - stringTokenizer.stringView = stringView - } - } - } - var lineManager: LineManager { - didSet { - if lineManager !== oldValue { - lineNavigationService.lineManager = lineManager - stringTokenizer.lineManager = lineManager - } - } - } - var lineControllerStorage: LineControllerStorage { - get { - lineNavigationService.lineControllerStorage - } - set { - lineNavigationService.lineControllerStorage = newValue - } - } - - private struct LineMovementOperation { - let sourceLocation: Int - let offset: Int - } - - private let lineNavigationService: LineNavigationService - private let stringTokenizer: StringTokenizer - private var previousLineMovementOperation: LineMovementOperation? - - init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { - self.stringView = stringView - self.lineManager = lineManager - self.lineNavigationService = LineNavigationService(lineManager: lineManager, lineControllerStorage: lineControllerStorage) - self.stringTokenizer = StringTokenizer(stringView: stringView, lineManager: lineManager, lineControllerStorage: lineControllerStorage) - } - - func location(movingFrom sourceLocation: Int, by offset: Int, granularity: TextGranularity) -> Int { - switch granularity { - case .character: - return location(movingFrom: sourceLocation, byCharacterCount: offset) - case .word: - return location(movingFrom: sourceLocation, byWordCount: offset) - case .line: - return location(movingFrom: sourceLocation, byLineCount: offset) - } - } - - func location(movingFrom sourceLocation: Int, toBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> Int { - switch boundary { - case .line: - let mappedDirection = StringTokenizer.Direction(direction) - return stringTokenizer.location(from: sourceLocation, toBoundary: .line, inDirection: mappedDirection) ?? sourceLocation - case .paragraph: - let mappedDirection = StringTokenizer.Direction(direction) - return stringTokenizer.location(from: sourceLocation, toBoundary: .paragraph, inDirection: mappedDirection) ?? sourceLocation - case .document: - let mappedDirection = StringTokenizer.Direction(direction) - return stringTokenizer.location(from: sourceLocation, toBoundary: .document, inDirection: mappedDirection) ?? sourceLocation - } - } - - func resetPreviousLineMovementOperation() { - previousLineMovementOperation = nil - } -} - -private extension NavigationService { - private func location(movingFrom sourceLocation: Int, byCharacterCount offset: Int) -> Int { - let naiveNewLocation = sourceLocation + offset - guard naiveNewLocation >= 0 && naiveNewLocation <= stringView.string.length else { - return sourceLocation - } - guard naiveNewLocation > 0 && naiveNewLocation < stringView.string.length else { - return naiveNewLocation - } - let range = stringView.string.customRangeOfComposedCharacterSequence(at: naiveNewLocation) - guard naiveNewLocation > range.location && naiveNewLocation < range.location + range.length else { - return naiveNewLocation - } - if offset < 0 { - return sourceLocation - range.length - } else { - return sourceLocation + range.length - } - } - - private func location(movingFrom sourceLocation: Int, byLineCount offset: Int) -> Int { - #if os(iOS) - return lineNavigationService.location(movingFrom: sourceLocation, byOffset: offset) - #else - // Attempts to simulate the behavior of UIKit and UITextInput. By using previousLineMovementOperation we can remember the local location within lines when navigating to shorter lines. - if let previousLineMovementOperation { - let newOffset = previousLineMovementOperation.offset + offset - let overridenSourceLocation = previousLineMovementOperation.sourceLocation - let destinationLocation = lineNavigationService.location(movingFrom: overridenSourceLocation, byOffset: newOffset) - // Only store the updated offset if the destination location is different from the source location. - // Otherwise the user can jump to the end of the document multiple times by pressing the down key and will need to press the up key multiple times to jump back. - if destinationLocation != sourceLocation { - self.previousLineMovementOperation = LineMovementOperation(sourceLocation: overridenSourceLocation, offset: newOffset) - } - return destinationLocation - } else { - previousLineMovementOperation = LineMovementOperation(sourceLocation: sourceLocation, offset: offset) - return lineNavigationService.location(movingFrom: sourceLocation, byOffset: offset) - } - #endif - } - - private func location(movingFrom sourceLocation: Int, byWordCount offset: Int) -> Int { - // This attempts to reproduce the logic of UIKit and UITextInput calling an instance of UITextInputTokenizer. - let direction: StringTokenizer.Direction = offset > 0 ? .forward : .backward - var destinationLocation: Int? = sourceLocation - var remainingOffset = abs(offset) - // Run once for each word that we should offset. - while let newSourceLocation = destinationLocation, remainingOffset > 0 { - guard let tmpDestinationLocation = stringTokenizer.location( - from: newSourceLocation, - toBoundary: .word, - inDirection: direction - ) else { - destinationLocation = nil - continue - } - // If we end up at the boundary of a word then we run once more. - if stringTokenizer.isLocation(tmpDestinationLocation, atBoundary: .word, inDirection: direction.opposite) { - remainingOffset += 1 - } - destinationLocation = tmpDestinationLocation - remainingOffset -= 1 - } - return destinationLocation ?? sourceLocation - } -} - -private extension StringTokenizer.Direction { - init(_ direction: TextDirection) { - switch direction { - case .forward: - self = .forward - case .backward: - self = .backward - } - } - - var opposite: StringTokenizer.Direction { - switch self { - case .forward: - return .backward - case .backward: - return .forward - } - } -} diff --git a/Sources/Runestone/TextView/Core/TextDirection.swift b/Sources/Runestone/TextView/Core/TextDirection.swift deleted file mode 100644 index 56a7648b9..000000000 --- a/Sources/Runestone/TextView/Core/TextDirection.swift +++ /dev/null @@ -1,4 +0,0 @@ -enum TextDirection { - case forward - case backward -} diff --git a/Sources/Runestone/TextView/Navigation/TextLocation.swift b/Sources/Runestone/TextView/Core/TextLocation.swift similarity index 100% rename from Sources/Runestone/TextView/Navigation/TextLocation.swift rename to Sources/Runestone/TextView/Core/TextLocation.swift diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift index ec0a2f330..5f4afcecd 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift @@ -2,81 +2,66 @@ import Foundation extension TextViewController { func moveLeft() { - navigationService.resetPreviousLineMovementOperation() - resetPreviouslySelectedRange() + navigationService.resetPreviousLineNavigationOperation() move(by: .character, inDirection: .backward) } func moveRight() { - navigationService.resetPreviousLineMovementOperation() - resetPreviouslySelectedRange() + navigationService.resetPreviousLineNavigationOperation() move(by: .character, inDirection: .forward) } func moveUp() { - resetPreviouslySelectedRange() move(by: .line, inDirection: .backward) } func moveDown() { - resetPreviouslySelectedRange() move(by: .line, inDirection: .forward) } func moveWordLeft() { - navigationService.resetPreviousLineMovementOperation() - resetPreviouslySelectedRange() + navigationService.resetPreviousLineNavigationOperation() move(by: .word, inDirection: .backward) } func moveWordRight() { - navigationService.resetPreviousLineMovementOperation() - resetPreviouslySelectedRange() + navigationService.resetPreviousLineNavigationOperation() move(by: .word, inDirection: .forward) } func moveToBeginningOfLine() { - navigationService.resetPreviousLineMovementOperation() - resetPreviouslySelectedRange() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .line, inDirection: .backward) } func moveToEndOfLine() { - navigationService.resetPreviousLineMovementOperation() - resetPreviouslySelectedRange() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .line, inDirection: .forward) } func moveToBeginningOfParagraph() { - navigationService.resetPreviousLineMovementOperation() - resetPreviouslySelectedRange() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .paragraph, inDirection: .backward) } func moveToEndOfParagraph() { - navigationService.resetPreviousLineMovementOperation() - resetPreviouslySelectedRange() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .paragraph, inDirection: .forward) } func moveToBeginningOfDocument() { - navigationService.resetPreviousLineMovementOperation() - resetPreviouslySelectedRange() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .document, inDirection: .backward) } func moveToEndOfDocument() { - navigationService.resetPreviousLineMovementOperation() - resetPreviouslySelectedRange() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .document, inDirection: .forward) } - func moveToLocation(closestTo point: CGPoint) { - if let location = layoutManager.closestIndex(to: point) { - navigationService.resetPreviousLineMovementOperation() - resetPreviouslySelectedRange() - selectedRange = NSRange(location: location, length: 0) - } + func move(to location: Int) { + navigationService.resetPreviousLineNavigationOperation() + selectedRange = NSRange(location: location, length: 0) } } @@ -85,16 +70,23 @@ private extension TextViewController { guard let selectedRange = selectedRange?.nonNegativeLength else { return } - let shouldMoveToSelectionEnd = selectedRange.length > 0 && granularity == .character - if shouldMoveToSelectionEnd && direction == .forward { - self.selectedRange = NSRange(location: selectedRange.upperBound, length: 0) - } else if shouldMoveToSelectionEnd && direction == .backward { - self.selectedRange = NSRange(location: selectedRange.lowerBound, length: 0) - } else { - let offset = direction == .forward ? 1 : -1 - let sourceLocation = direction == .forward ? selectedRange.upperBound : selectedRange.lowerBound - let destinationLocation = navigationService.location(movingFrom: sourceLocation, by: offset, granularity: granularity) - self.selectedRange = NSRange(location: destinationLocation, length: 0) + switch granularity { + case .character: + if selectedRange.length == 0 { + let sourceLocation = selectedRange.bound(in: direction) + let location = navigationService.location(movingFrom: sourceLocation, byCharacterCount: 1, inDirection: direction) + self.selectedRange = NSRange(location: location, length: 0) + } else { + let location = selectedRange.bound(in: direction) + self.selectedRange = NSRange(location: location, length: 0) + } + case .line: + let location = navigationService.location(movingFrom: selectedRange.location, byLineCount: 1, inDirection: direction) + self.selectedRange = NSRange(location: location, length: 0) + case .word: + let sourceLocation = selectedRange.bound(in: direction) + let location = navigationService.location(movingFrom: sourceLocation, byWordCount: 1, inDirection: direction) + self.selectedRange = NSRange(location: location, length: 0) } } @@ -102,14 +94,19 @@ private extension TextViewController { guard let selectedRange = selectedRange?.nonNegativeLength else { return } - let sourceLocation = direction == .forward ? selectedRange.upperBound : selectedRange.lowerBound - let destinationLocation = navigationService.location(movingFrom: sourceLocation, toBoundary: boundary, inDirection: direction) - self.selectedRange = NSRange(location: destinationLocation, length: 0) + let sourceLocation = selectedRange.bound(in: direction) + let location = navigationService.location(moving: sourceLocation, toBoundary: boundary, inDirection: direction) + self.selectedRange = NSRange(location: location, length: 0) } +} - private func resetPreviouslySelectedRange() { - #if os(macOS) - selectionService.resetPreviouslySelectedRange() - #endif +private extension NSRange { + func bound(in direction: TextDirection) -> Int { + switch direction { + case .backward: + return lowerBound + case .forward: + return upperBound + } } } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift index c8d932a8d..4c27e54ba 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift @@ -3,12 +3,12 @@ import Foundation extension TextViewController { func moveLeftAndModifySelection() { - navigationService.resetPreviousLineMovementOperation() + navigationService.resetPreviousLineNavigationOperation() move(by: .character, inDirection: .backward) } func moveRightAndModifySelection() { - navigationService.resetPreviousLineMovementOperation() + navigationService.resetPreviousLineNavigationOperation() move(by: .character, inDirection: .forward) } @@ -21,56 +21,64 @@ extension TextViewController { } func moveWordLeftAndModifySelection() { - navigationService.resetPreviousLineMovementOperation() + navigationService.resetPreviousLineNavigationOperation() move(by: .word, inDirection: .backward) } func moveWordRightAndModifySelection() { - navigationService.resetPreviousLineMovementOperation() + navigationService.resetPreviousLineNavigationOperation() move(by: .word, inDirection: .forward) } func moveToBeginningOfLineAndModifySelection() { - navigationService.resetPreviousLineMovementOperation() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .line, inDirection: .backward) } func moveToEndOfLineAndModifySelection() { - navigationService.resetPreviousLineMovementOperation() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .line, inDirection: .forward) } func moveToBeginningOfParagraphAndModifySelection() { - navigationService.resetPreviousLineMovementOperation() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .paragraph, inDirection: .backward) } func moveToEndOfParagraphAndModifySelection() { - navigationService.resetPreviousLineMovementOperation() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .paragraph, inDirection: .forward) } func moveToBeginningOfDocumentAndModifySelection() { - navigationService.resetPreviousLineMovementOperation() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .document, inDirection: .backward) } func moveToEndOfDocumentAndModifySelection() { - navigationService.resetPreviousLineMovementOperation() + navigationService.resetPreviousLineNavigationOperation() move(toBoundary: .document, inDirection: .forward) } + + func startDraggingSelection(from location: Int) { + selectedRange = selectionService.rangeByStartDraggingSelection(from: location) + } + + func extendDraggedSelection(to location: Int) { + selectedRange = selectionService.rangeByExtendingDraggedSelection(to: location) + } } private extension TextViewController { - private func move(by granularity: TextGranularity, inDirection directon: TextDirection) { - if let currentlySelectedRange = selectedRange { - selectedRange = selectionService.range(movingFrom: currentlySelectedRange, by: granularity, inDirection: directon) + private func move(by granularity: TextGranularity, inDirection direction: TextDirection) { + if let selectedRange { + self.selectedRange = selectionService.range(moving: selectedRange, by: granularity, inDirection: direction) } } - private func move(toBoundary boundary: TextBoundary, inDirection directon: TextDirection) { - if let currentlySelectedRange = selectedRange { - selectedRange = selectionService.range(movingFrom: currentlySelectedRange, toBoundary: boundary, inDirection: directon) + private func move(toBoundary boundary: TextBoundary, inDirection direction: TextDirection) { + if let selectedRange { + self.selectedRange = selectionService.range(moving: selectedRange, toBoundary: boundary, inDirection: direction) } } } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 97e86117d..8b31fd46d 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -149,6 +149,7 @@ final class TextViewController { layoutManager.stringView = stringView indentController.stringView = stringView navigationService.stringView = stringView + selectionService.stringView = stringView } } } @@ -159,8 +160,9 @@ final class TextViewController { indentController.lineManager = lineManager gutterWidthService.lineManager = lineManager contentSizeService.lineManager = lineManager - navigationService.lineManager = lineManager highlightService.lineManager = lineManager + navigationService.lineManager = lineManager + selectionService.lineManager = lineManager } } } @@ -562,7 +564,11 @@ final class TextViewController { lineControllerStorage: lineControllerStorage ) #if os(macOS) - selectionService = SelectionService(navigationService: navigationService) + selectionService = SelectionService( + stringView: stringView, + lineManager: lineManager, + lineControllerStorage: lineControllerStorage + ) #endif layoutManager = LayoutManager( lineManager: lineManager, diff --git a/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift index eda780661..45e39c187 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift @@ -39,10 +39,10 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { guard let indexedPosition = position as? IndexedPosition else { return false } - guard let boundary = StringTokenizer.Boundary(granularity) else { + guard let boundary = TextBoundary(granularity) else { return super.isPosition(position, atBoundary: granularity, inDirection: direction) } - let direction = StringTokenizer.Direction(direction) + let direction = TextDirection(direction) return stringTokenizer.isLocation(indexedPosition.index, atBoundary: boundary, inDirection: direction) } @@ -62,10 +62,10 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { guard let indexedPosition = position as? IndexedPosition else { return nil } - guard let boundary = StringTokenizer.Boundary(granularity) else { + guard let boundary = TextBoundary(granularity) else { return super.position(from: position, toBoundary: granularity, inDirection: direction) } - let direction = StringTokenizer.Direction(direction) + let direction = TextDirection(direction) guard let location = stringTokenizer.location(from: indexedPosition.index, toBoundary: boundary, inDirection: direction) else { return nil } @@ -81,7 +81,7 @@ final class TextInputStringTokenizer: UITextInputStringTokenizer { } } -private extension StringTokenizer.Boundary { +private extension TextBoundary { init?(_ granularity: UITextGranularity) { switch granularity { case .word: @@ -90,7 +90,9 @@ private extension StringTokenizer.Boundary { self = .paragraph case .line: self = .line - case .character, .document, .sentence: + case .document: + self = .document + case .character, .sentence: return nil @unknown default: return nil @@ -98,7 +100,7 @@ private extension StringTokenizer.Boundary { } } -private extension StringTokenizer.Direction { +private extension TextDirection { init(_ direction: UITextDirection) { if direction.isForward { self = .forward diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index 2385d1961..61a0271a6 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -305,16 +305,16 @@ public extension TextView { let navigationService = textViewController.navigationService switch direction { case .right: - let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: offset, granularity: .character) + let newLocation = navigationService.location(movingFrom: indexedPosition.index, byCharacterCount: offset, inDirection: .forward) return IndexedPosition(index: newLocation) case .left: - let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: offset * -1, granularity: .character) + let newLocation = navigationService.location(movingFrom: indexedPosition.index, byCharacterCount: offset, inDirection: .backward) return IndexedPosition(index: newLocation) case .up: - let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: offset * -1, granularity: .line) + let newLocation = navigationService.location(movingFrom: indexedPosition.index, byLineCount: offset, inDirection: .backward) return IndexedPosition(index: newLocation) case .down: - let newLocation = navigationService.location(movingFrom: indexedPosition.index, by: offset, granularity: .line) + let newLocation = navigationService.location(movingFrom: indexedPosition.index, byLineCount: offset, inDirection: .forward) return IndexedPosition(index: newLocation) @unknown default: return nil diff --git a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/CharacterNavigationLocationFactory.swift b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/CharacterNavigationLocationFactory.swift new file mode 100644 index 000000000..1ac4895c5 --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/CharacterNavigationLocationFactory.swift @@ -0,0 +1,35 @@ +import Foundation + +struct CharacterNavigationLocationFactory { + private let stringView: StringView + + init(stringView: StringView) { + self.stringView = stringView + } + + func location(movingFrom sourceLocation: Int, byCharacterCount offset: Int = 1, inDirection direction: TextDirection) -> Int { + let naiveNewLocation: Int + switch direction { + case .forward: + naiveNewLocation = sourceLocation + offset + case .backward: + naiveNewLocation = sourceLocation - offset + } + guard naiveNewLocation >= 0 && naiveNewLocation <= stringView.string.length else { + return sourceLocation + } + guard naiveNewLocation > 0 && naiveNewLocation < stringView.string.length else { + return naiveNewLocation + } + let range = stringView.string.customRangeOfComposedCharacterSequence(at: naiveNewLocation) + guard naiveNewLocation > range.location && naiveNewLocation < range.location + range.length else { + return naiveNewLocation + } + switch direction { + case .forward: + return sourceLocation + range.length + case .backward: + return sourceLocation - range.length + } + } +} diff --git a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift new file mode 100644 index 000000000..95ed4733b --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift @@ -0,0 +1,83 @@ +import Foundation + +final class ConsiderateLineNavigationLocationFactory { + private struct MoveOperation { + let location: Int + let offset: DirectionedOffset + let destinationLocation: Int + } + + fileprivate struct DirectionedOffset { + let rawValue: Int + var offset: Int { + abs(rawValue) + } + var direction: TextDirection { + rawValue < 0 ? .backward : .forward + } + + init(offset: Int, inDirection direction: TextDirection) { + switch direction { + case .forward: + rawValue = offset < 0 ? offset * -1 : offset + case .backward: + rawValue = offset > 0 ? offset * -1 : offset + } + } + + fileprivate init(rawValue: Int) { + self.rawValue = rawValue + } + } + + var lineManager: LineManager + var lineControllerStorage: LineControllerStorage + + private var previousOperation: MoveOperation? + private var lineNavigationLocationFactory: LineNavigationLocationFactory { + LineNavigationLocationFactory(lineManager: lineManager, lineControllerStorage: lineControllerStorage) + } + + init(lineManager: LineManager, lineControllerStorage: LineControllerStorage) { + self.lineManager = lineManager + self.lineControllerStorage = lineControllerStorage + } + + func location(movingFrom location: Int, byLineCount offset: Int, inDirection direction: TextDirection) -> Int { + if let previousOperation { + let directionedOffset = DirectionedOffset(offset: offset, inDirection: direction) + let newDirectionedOffset = previousOperation.offset + directionedOffset + let newOperation = operation( + movingFrom: previousOperation.location, + byLineCount: newDirectionedOffset.offset, + inDirection: newDirectionedOffset.direction + ) + if newOperation.destinationLocation != previousOperation.destinationLocation { + self.previousOperation = newOperation + } + return newOperation.destinationLocation + } else { + let operation = operation(movingFrom: location, byLineCount: offset, inDirection: direction) + previousOperation = operation + return operation.destinationLocation + } + } + + func reset() { + previousOperation = nil + } +} + +private extension ConsiderateLineNavigationLocationFactory { + private func operation(movingFrom location: Int, byLineCount offset: Int, inDirection direction: TextDirection) -> MoveOperation { + let directionedOffset = DirectionedOffset(offset: offset, inDirection: direction) + let destinationLocation = lineNavigationLocationFactory.location(movingFrom: location, byLineCount: offset, inDirection: direction) + return MoveOperation(location: location, offset: directionedOffset, destinationLocation: destinationLocation) + } +} + +extension ConsiderateLineNavigationLocationFactory.DirectionedOffset { + static func +(lhs: Self, rhs: Self) -> Self { + Self(rawValue: lhs.rawValue + rhs.rawValue) + } +} diff --git a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/LineNavigationLocationFactory.swift b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/LineNavigationLocationFactory.swift new file mode 100644 index 000000000..703e32072 --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/LineNavigationLocationFactory.swift @@ -0,0 +1,114 @@ +import Foundation + +struct LineNavigationLocationFactory { + private let lineManager: LineManager + private let lineControllerStorage: LineControllerStorage + + init(lineManager: LineManager, lineControllerStorage: LineControllerStorage) { + self.lineManager = lineManager + self.lineControllerStorage = lineControllerStorage + } + + func location(movingFrom sourceLocation: Int, byLineCount offset: Int = 1, inDirection direction: TextDirection) -> Int { + guard let line = lineManager.line(containingCharacterAt: sourceLocation) else { + return sourceLocation + } + guard let lineController = lineControllerStorage[line.id] else { + return sourceLocation + } + let lineLocalLocation = max(min(sourceLocation - line.location, line.data.totalLength), 0) + guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { + return sourceLocation + } + let lineFragmentLocalLocation = lineLocalLocation - lineFragmentNode.location + return location( + movingFrom: lineFragmentLocalLocation, + inLineFragmentAt: lineFragmentNode.index, + of: line, + offset: offset, + inDirection: direction + ) + } + + func location( + movingFrom location: Int, + inLineFragmentAt lineFragmentIndex: Int, + of line: DocumentLineNode, + offset: Int = 1, + inDirection direction: TextDirection + ) -> Int { + if offset == 0 { + return self.location(movingFrom: location, inLineFragmentAt: lineFragmentIndex, of: line) + } else { + switch direction { + case .forward: + return self.location(movingForwardsFrom: location, inLineFragmentAt: lineFragmentIndex, of: line, offset: offset) + case .backward: + return self.location(movingBackwardsFrom: location, inLineFragmentAt: lineFragmentIndex, of: line, offset: offset) + } + } + } +} + +private extension LineNavigationLocationFactory { + private func location(movingFrom location: Int, inLineFragmentAt lineFragmentIndex: Int, of line: DocumentLineNode) -> Int { + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + let destinationLineFragmentNode = lineController.lineFragmentNode(atIndex: lineFragmentIndex) + let lineLocation = line.location + let preferredLocation = lineLocation + destinationLineFragmentNode.location + location + // Subtract 1 from the maximum location in the line fragment to ensure the caret is not placed on the next line fragment when navigating to the end of a line fragment. This aligns with the behavior of popular text editors. + let lineFragmentMaximumLocation = lineLocation + destinationLineFragmentNode.location + destinationLineFragmentNode.value - 1 + let lineMaximumLocation = lineLocation + line.data.length + let maximumLocation = min(lineFragmentMaximumLocation, lineMaximumLocation) + return min(preferredLocation, maximumLocation) + } + + private func location(movingBackwardsFrom location: Int, inLineFragmentAt lineFragmentIndex: Int, of line: DocumentLineNode, offset: Int) -> Int { + let takeLineCount = min(lineFragmentIndex, offset) + let remainingOffset = offset - takeLineCount + guard remainingOffset > 0 else { + return self.location( + movingFrom: location, + inLineFragmentAt: lineFragmentIndex - takeLineCount, + of: line, + offset: 0, + inDirection: .backward + ) + } + let lineIndex = line.index + guard lineIndex > 0 else { + // We've reached the beginning of the document so we move to the first character. + return 0 + } + let previousLine = lineManager.line(atRow: lineIndex - 1) + let numberOfLineFragments = numberOfLineFragments(in: previousLine) + let newLineFragmentIndex = numberOfLineFragments - 1 + return self.location(movingBackwardsFrom: location, inLineFragmentAt: newLineFragmentIndex, of: previousLine, offset: remainingOffset - 1) + } + + private func location(movingForwardsFrom location: Int, inLineFragmentAt lineFragmentIndex: Int, of line: DocumentLineNode, offset: Int) -> Int { + let numberOfLineFragments = numberOfLineFragments(in: line) + let takeLineCount = min(numberOfLineFragments - lineFragmentIndex - 1, offset) + let remainingOffset = offset - takeLineCount + guard remainingOffset > 0 else { + return self.location( + movingFrom: location, + inLineFragmentAt: lineFragmentIndex + takeLineCount, + of: line, + offset: 0, + inDirection: .forward + ) + } + let lineIndex = line.index + guard lineIndex < lineManager.lineCount - 1 else { + // We've reached the end of the document so we move to the last character. + return line.location + line.data.totalLength + } + let nextLine = lineManager.line(atRow: lineIndex + 1) + return self.location(movingForwardsFrom: location, inLineFragmentAt: 0, of: nextLine, offset: remainingOffset - 1) + } + + private func numberOfLineFragments(in line: DocumentLineNode) -> Int { + lineControllerStorage.getOrCreateLineController(for: line).numberOfLineFragments + } +} diff --git a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/WordNavigationLocationFactory.swift b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/WordNavigationLocationFactory.swift new file mode 100644 index 000000000..9f9bbafa6 --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/WordNavigationLocationFactory.swift @@ -0,0 +1,19 @@ +import Foundation + +struct WordNavigationLocationFactory { + private let stringTokenizer: StringTokenizer + + init(stringTokenizer: StringTokenizer) { + self.stringTokenizer = stringTokenizer + } + + func location(movingFrom sourceLocation: Int, byWordCount offset: Int = 1, inDirection direction: TextDirection) -> Int { + var destinationLocation: Int? = sourceLocation + var remainingOffset = offset + while let newSourceLocation = destinationLocation, remainingOffset > 0 { + destinationLocation = stringTokenizer.location(from: newSourceLocation, toBoundary: .word, inDirection: direction) + remainingOffset -= 1 + } + return destinationLocation ?? sourceLocation + } +} diff --git a/Sources/Runestone/TextView/Navigation/NavigationService.swift b/Sources/Runestone/TextView/Navigation/NavigationService.swift new file mode 100644 index 000000000..34221aa8a --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/NavigationService.swift @@ -0,0 +1,54 @@ +import Foundation + +final class NavigationService { + var stringView: StringView + var lineManager: LineManager { + didSet { + if lineManager !== oldValue { + lineNavigationLocationService.lineManager = lineManager + } + } + } + + private let lineControllerStorage: LineControllerStorage + private var stringTokenizer: StringTokenizer { + StringTokenizer(stringView: stringView, lineManager: lineManager, lineControllerStorage: lineControllerStorage) + } + private var characterNavigationLocationService: CharacterNavigationLocationFactory { + CharacterNavigationLocationFactory(stringView: stringView) + } + private var wordNavigationLocationService: WordNavigationLocationFactory { + WordNavigationLocationFactory(stringTokenizer: stringTokenizer) + } + private var lineNavigationLocationService: ConsiderateLineNavigationLocationFactory + + init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { + self.stringView = stringView + self.lineManager = lineManager + self.lineControllerStorage = lineControllerStorage + self.lineNavigationLocationService = ConsiderateLineNavigationLocationFactory( + lineManager: lineManager, + lineControllerStorage: lineControllerStorage + ) + } + + func location(movingFrom location: Int, byCharacterCount offset: Int, inDirection direction: TextDirection) -> Int { + characterNavigationLocationService.location(movingFrom: location, byCharacterCount: offset, inDirection: direction) + } + + func location(movingFrom location: Int, byLineCount offset: Int, inDirection direction: TextDirection) -> Int { + lineNavigationLocationService.location(movingFrom: location, byLineCount: offset, inDirection: direction) + } + + func location(movingFrom sourceLocation: Int, byWordCount offset: Int, inDirection direction: TextDirection) -> Int { + wordNavigationLocationService.location(movingFrom: sourceLocation, byWordCount: offset, inDirection: direction) + } + + func location(moving sourceLocation: Int, toBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> Int { + stringTokenizer.location(from: sourceLocation, toBoundary: boundary, inDirection: direction) ?? sourceLocation + } + + func resetPreviousLineNavigationOperation() { + lineNavigationLocationService.reset() + } +} diff --git a/Sources/Runestone/TextView/Navigation/SelectionService.swift b/Sources/Runestone/TextView/Navigation/SelectionService.swift new file mode 100644 index 000000000..46f163005 --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/SelectionService.swift @@ -0,0 +1,102 @@ +#if os(macOS) +import Foundation + +final class SelectionService { + var stringView: StringView + var lineManager: LineManager + var lineControllerStorage: LineControllerStorage + + private var anchoringDirection: TextDirection? + private var selectionOrigin: Int? + + private var stringTokenizer: StringTokenizer { + StringTokenizer(stringView: stringView, lineManager: lineManager, lineControllerStorage: lineControllerStorage) + } + private var characterNavigationLocationService: CharacterNavigationLocationFactory { + CharacterNavigationLocationFactory(stringView: stringView) + } + private var wordNavigationLocationService: WordNavigationLocationFactory { + WordNavigationLocationFactory(stringTokenizer: stringTokenizer) + } + private var lineNavigationLocationService: LineNavigationLocationFactory { + LineNavigationLocationFactory(lineManager: lineManager, lineControllerStorage: lineControllerStorage) + } + + init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { + self.stringView = stringView + self.lineManager = lineManager + self.lineControllerStorage = lineControllerStorage + } + + func range(moving range: NSRange, by granularity: TextGranularity, inDirection direction: TextDirection) -> NSRange { + if range.length == 0 { + selectionOrigin = range.location + } + let anchoringDirection = anchoringDirection(moving: range, inDirection: direction) + switch (granularity, anchoringDirection) { + case (.character, .backward): + let upperBound = characterNavigationLocationService.location(movingFrom: range.upperBound, inDirection: direction) + return range.withUpperBound(upperBound) + case (.character, .forward): + let lowerBound = characterNavigationLocationService.location(movingFrom: range.lowerBound, inDirection: direction) + return range.withLowerBound(lowerBound) + case (.word, .backward): + let upperBound = wordNavigationLocationService.location(movingFrom: range.upperBound, inDirection: direction) + return range.withUpperBound(upperBound) + case (.word, .forward): + let lowerBound = wordNavigationLocationService.location(movingFrom: range.lowerBound, inDirection: direction) + return range.withLowerBound(lowerBound) + case (.line, .backward): + let upperBound = lineNavigationLocationService.location(movingFrom: range.upperBound, inDirection: direction) + return range.withUpperBound(upperBound) + case (.line, .forward): + let lowerBound = lineNavigationLocationService.location(movingFrom: range.lowerBound, inDirection: direction) + return range.withLowerBound(lowerBound) + } + } + + func range(moving range: NSRange, toBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> NSRange { + if range.length == 0 { + selectionOrigin = range.location + } + return range + } + + func rangeByStartDraggingSelection(from location: Int) -> NSRange { + let range = NSRange(location: location, length: 0) + selectionOrigin = location + return range + } + + func rangeByExtendingDraggedSelection(to location: Int) -> NSRange { + guard let selectionOrigin else { + return NSRange(location: location, length: 0) + } + let lowerBound = min(selectionOrigin, location) + let upperBound = max(selectionOrigin, location) + return NSRange(location: lowerBound, length: upperBound - lowerBound) + } +} + +private extension SelectionService { + private func anchoringDirection(moving range: NSRange, inDirection direction: TextDirection) -> TextDirection { + if range.upperBound == selectionOrigin { + return .forward + } else { + return .backward + } + } +} + +private extension NSRange { + func withLowerBound(_ newLowerBound: Int) -> NSRange { + let newLength = upperBound - newLowerBound + return NSRange(location: newLowerBound, length: newLength) + } + + func withUpperBound(_ newUpperBound: Int) -> NSRange { + let newLength = newUpperBound - lowerBound + return NSRange(location: lowerBound, length: newLength) + } +} +#endif diff --git a/Sources/Runestone/TextView/Core/StringTokenizer.swift b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift similarity index 76% rename from Sources/Runestone/TextView/Core/StringTokenizer.swift rename to Sources/Runestone/TextView/Navigation/StringTokenizer.swift index 10fcc2c1d..c5b175a2b 100644 --- a/Sources/Runestone/TextView/Core/StringTokenizer.swift +++ b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift @@ -1,18 +1,6 @@ import Foundation final class StringTokenizer { - enum Boundary { - case word - case line - case paragraph - case document - } - - enum Direction { - case forward - case backward - } - var lineManager: LineManager var stringView: StringView @@ -27,7 +15,7 @@ final class StringTokenizer { self.lineControllerStorage = lineControllerStorage } - func isLocation(_ location: Int, atBoundary boundary: Boundary, inDirection direction: Direction) -> Bool { + func isLocation(_ location: Int, atBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> Bool { switch boundary { case .word: return isLocation(location, atWordBoundaryInDirection: direction) @@ -40,7 +28,7 @@ final class StringTokenizer { } } - func location(from location: Int, toBoundary boundary: Boundary, inDirection direction: Direction) -> Int? { + func location(from location: Int, toBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> Int? { switch boundary { case .word: return self.location(from: location, toWordBoundaryInDirection: direction) @@ -56,7 +44,7 @@ final class StringTokenizer { // MARK: - Lines private extension StringTokenizer { - private func isLocation(_ location: Int, atLineBoundaryInDirection direction: Direction) -> Bool { + private func isLocation(_ location: Int, atLineBoundaryInDirection direction: TextDirection) -> Bool { guard let line = lineManager.line(containingCharacterAt: location) else { return false } @@ -69,19 +57,20 @@ private extension StringTokenizer { guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { return false } - if direction == .forward { + switch direction { + case .forward: let isLastLineFragment = lineFragmentNode.index == lineController.numberOfLineFragments - 1 if isLastLineFragment { return location == lineLocation + lineFragmentNode.location + lineFragmentNode.value - line.data.delimiterLength } else { return location == lineLocation + lineFragmentNode.location + lineFragmentNode.value } - } else { + case .backward: return location == lineLocation + lineFragmentNode.location } } - private func location(from location: Int, toLineBoundaryInDirection direction: Direction) -> Int? { + private func location(from location: Int, toLineBoundaryInDirection direction: TextDirection) -> Int? { guard let line = lineManager.line(containingCharacterAt: location) else { return nil } @@ -117,14 +106,15 @@ private extension StringTokenizer { // MARK: - Paragraphs private extension StringTokenizer { - private func isLocation(_ location: Int, atParagraphBoundaryInDirection direction: Direction) -> Bool { + private func isLocation(_ location: Int, atParagraphBoundaryInDirection direction: TextDirection) -> 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 location(from location: Int, toParagraphBoundaryInDirection direction: Direction) -> Int? { - if direction == .forward { + private func location(from location: Int, toParagraphBoundaryInDirection direction: TextDirection) -> Int? { + switch direction { + case .forward: if location == stringView.string.length { return location } else { @@ -140,7 +130,7 @@ private extension StringTokenizer { } return currentIndex } - } else { + case .backward: if location == 0 { return location } else { @@ -163,9 +153,10 @@ private extension StringTokenizer { // MARK: - Words private extension StringTokenizer { - private func isLocation(_ location: Int, atWordBoundaryInDirection direction: Direction) -> Bool { + private func isLocation(_ location: Int, atWordBoundaryInDirection direction: TextDirection) -> Bool { let alphanumerics: CharacterSet = .alphanumerics - if direction == .forward { + switch direction { + case .forward: if location == 0 { return false } else if let previousCharacter = stringView.composedSubstring(at: location - 1) { @@ -179,7 +170,7 @@ private extension StringTokenizer { } else { return false } - } else { + case .backward: if location == stringView.string.length { return false } else if let string = stringView.composedSubstring(at: location) { @@ -197,56 +188,31 @@ private extension StringTokenizer { } // swiftlint:disable:next cyclomatic_complexity - private func location(from location: Int, toWordBoundaryInDirection direction: Direction) -> Int? { - let alphanumerics: CharacterSet = .alphanumerics - if direction == .forward { - if location == stringView.string.length { - return location - } else if let referenceString = stringView.composedSubstring(at: location) { - let isReferenceStringAlphanumeric = alphanumerics.containsAllCharacters(of: referenceString) - var currentIndex = location + 1 - while currentIndex < stringView.string.length { - guard let currentString = stringView.composedSubstring(at: currentIndex) else { - break - } - let isCurrentStringAlphanumeric = alphanumerics.containsAllCharacters(of: currentString) - if isReferenceStringAlphanumeric != isCurrentStringAlphanumeric { - break - } - currentIndex += 1 - } - return currentIndex - } else { - return nil - } - } else { - if location == 0 { - return location - } else if let referenceString = stringView.composedSubstring(at: location - 1) { - let isReferenceStringAlphanumeric = alphanumerics.containsAllCharacters(of: referenceString) - var currentIndex = location - 1 - while currentIndex > 0 { - guard let currentString = stringView.composedSubstring(at: currentIndex) else { - break - } - let isCurrentStringAlphanumeric = alphanumerics.containsAllCharacters(of: currentString) - if isReferenceStringAlphanumeric != isCurrentStringAlphanumeric { - currentIndex += 1 - break - } - currentIndex -= 1 - } - return currentIndex - } else { - return nil + private func location(from location: Int, toWordBoundaryInDirection direction: TextDirection) -> Int? { + func advanceIndex(_ index: Int) -> Int { + let preferredIndex: Int + switch direction { + case .forward: + preferredIndex = index + 1 + case .backward: + preferredIndex = index - 1 } + return min(max(preferredIndex, 0), stringView.string.length - 1) + } + var index = location + if isLocation(index, atBoundary: .word, inDirection: direction) { + index = advanceIndex(index) + } + while !isLocation(index, atBoundary: .word, inDirection: direction) && index >= 0 && index < stringView.string.length { + index = advanceIndex(index) } + return index } } // MARK: - Document private extension StringTokenizer { - private func isLocation(_ location: Int, atDocumentBoundaryInDirection direction: Direction) -> Bool { + private func isLocation(_ location: Int, atDocumentBoundaryInDirection direction: TextDirection) -> Bool { switch direction { case .backward: return location == 0 @@ -255,7 +221,7 @@ private extension StringTokenizer { } } - private func location(toDocumentBoundaryInDirection direction: Direction) -> Int { + private func location(toDocumentBoundaryInDirection direction: TextDirection) -> Int { switch direction { case .backward: return 0 diff --git a/Sources/Runestone/TextView/Core/TextBoundary.swift b/Sources/Runestone/TextView/Navigation/TextBoundary.swift similarity index 83% rename from Sources/Runestone/TextView/Core/TextBoundary.swift rename to Sources/Runestone/TextView/Navigation/TextBoundary.swift index 18e0940e7..e55c6ec96 100644 --- a/Sources/Runestone/TextView/Core/TextBoundary.swift +++ b/Sources/Runestone/TextView/Navigation/TextBoundary.swift @@ -1,4 +1,5 @@ enum TextBoundary { + case word case line case paragraph case document diff --git a/Sources/Runestone/TextView/Navigation/TextDirection.swift b/Sources/Runestone/TextView/Navigation/TextDirection.swift new file mode 100644 index 000000000..8a31be6a0 --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/TextDirection.swift @@ -0,0 +1,13 @@ +enum TextDirection { + case forward + case backward + + var opposite: Self { + switch self { + case .forward: + return .backward + case .backward: + return .forward + } + } +} diff --git a/Sources/Runestone/TextView/Core/TextGranularity.swift b/Sources/Runestone/TextView/Navigation/TextGranularity.swift similarity index 100% rename from Sources/Runestone/TextView/Core/TextGranularity.swift rename to Sources/Runestone/TextView/Navigation/TextGranularity.swift From 09d2c5bbf6de8c1046e1bf97589241a72e444f9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 9 Feb 2023 07:39:48 +0100 Subject: [PATCH 132/232] Layout selection rectangles when view size changes --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index f3e6ebee6..2a16c2fab 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -497,6 +497,7 @@ open class TextView: NSView { textViewController.layoutIfNeeded() textViewController.handleContentSizeUpdateIfNeeded() updateCaretFrame() + updateSelectedRectangles() } override public func layoutSubtreeIfNeeded() { From 7bd6480a31cf4a668cc9fa75afcb173407baf4c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 9 Feb 2023 07:54:54 +0100 Subject: [PATCH 133/232] Takes gutter width into account when typesetting --- Sources/Runestone/TextView/Core/LayoutManager.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index de5b4b581..4835cc09d 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -85,7 +85,10 @@ final class LayoutManager { var lineHeightMultiplier: CGFloat = 1 var constrainingLineWidth: CGFloat { if isLineWrappingEnabled { - return scrollViewWidth - textContainerInset.left - textContainerInset.right - safeAreaInsets.left - safeAreaInsets.right + return scrollViewWidth + - gutterWidthService.gutterWidth + - textContainerInset.left - textContainerInset.right + - safeAreaInsets.left - safeAreaInsets.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. From 9e3e0a7aec252a5e855809f1b0707bb3474a9a7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 9 Feb 2023 08:12:55 +0100 Subject: [PATCH 134/232] Improves naming of functions --- .../TextView/Navigation/SelectionService.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Runestone/TextView/Navigation/SelectionService.swift b/Sources/Runestone/TextView/Navigation/SelectionService.swift index 46f163005..258e9aacf 100644 --- a/Sources/Runestone/TextView/Navigation/SelectionService.swift +++ b/Sources/Runestone/TextView/Navigation/SelectionService.swift @@ -89,13 +89,13 @@ private extension SelectionService { } private extension NSRange { - func withLowerBound(_ newLowerBound: Int) -> NSRange { - let newLength = upperBound - newLowerBound - return NSRange(location: newLowerBound, length: newLength) + func withLowerBound(_ lowerBound: Int) -> NSRange { + let newLength = upperBound - lowerBound + return NSRange(location: lowerBound, length: newLength) } - func withUpperBound(_ newUpperBound: Int) -> NSRange { - let newLength = newUpperBound - lowerBound + func withUpperBound(_ upperBound: Int) -> NSRange { + let newLength = upperBound - lowerBound return NSRange(location: lowerBound, length: newLength) } } From 3512a8888a3bc3d3923c8a49dc484ebb5bcb2ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 9 Feb 2023 08:13:11 +0100 Subject: [PATCH 135/232] Uses supplied direction when length of range is zero --- Sources/Runestone/TextView/Navigation/SelectionService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Navigation/SelectionService.swift b/Sources/Runestone/TextView/Navigation/SelectionService.swift index 258e9aacf..6b3695d59 100644 --- a/Sources/Runestone/TextView/Navigation/SelectionService.swift +++ b/Sources/Runestone/TextView/Navigation/SelectionService.swift @@ -80,7 +80,9 @@ final class SelectionService { private extension SelectionService { private func anchoringDirection(moving range: NSRange, inDirection direction: TextDirection) -> TextDirection { - if range.upperBound == selectionOrigin { + if range.length == 0 { + return direction.opposite + } else if range.upperBound == selectionOrigin { return .forward } else { return .backward From 140f38feb68330c3aeeb9d00be3862f3000b92aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 9 Feb 2023 08:13:23 +0100 Subject: [PATCH 136/232] Uses ConsiderateLineNavigationLocationFactory --- .../Navigation/SelectionService.swift | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/Sources/Runestone/TextView/Navigation/SelectionService.swift b/Sources/Runestone/TextView/Navigation/SelectionService.swift index 6b3695d59..3666c5e04 100644 --- a/Sources/Runestone/TextView/Navigation/SelectionService.swift +++ b/Sources/Runestone/TextView/Navigation/SelectionService.swift @@ -3,9 +3,15 @@ import Foundation final class SelectionService { var stringView: StringView - var lineManager: LineManager - var lineControllerStorage: LineControllerStorage + var lineManager: LineManager { + didSet { + if lineManager !== oldValue { + lineNavigationLocationService.lineManager = lineManager + } + } + } + private let lineControllerStorage: LineControllerStorage private var anchoringDirection: TextDirection? private var selectionOrigin: Int? @@ -18,32 +24,39 @@ final class SelectionService { private var wordNavigationLocationService: WordNavigationLocationFactory { WordNavigationLocationFactory(stringTokenizer: stringTokenizer) } - private var lineNavigationLocationService: LineNavigationLocationFactory { - LineNavigationLocationFactory(lineManager: lineManager, lineControllerStorage: lineControllerStorage) - } + private let lineNavigationLocationService: ConsiderateLineNavigationLocationFactory init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { self.stringView = stringView self.lineManager = lineManager self.lineControllerStorage = lineControllerStorage + self.lineNavigationLocationService = ConsiderateLineNavigationLocationFactory( + lineManager: lineManager, + lineControllerStorage: lineControllerStorage + ) } func range(moving range: NSRange, by granularity: TextGranularity, inDirection direction: TextDirection) -> NSRange { if range.length == 0 { selectionOrigin = range.location + lineNavigationLocationService.reset() } let anchoringDirection = anchoringDirection(moving: range, inDirection: direction) switch (granularity, anchoringDirection) { case (.character, .backward): + lineNavigationLocationService.reset() let upperBound = characterNavigationLocationService.location(movingFrom: range.upperBound, inDirection: direction) return range.withUpperBound(upperBound) case (.character, .forward): + lineNavigationLocationService.reset() let lowerBound = characterNavigationLocationService.location(movingFrom: range.lowerBound, inDirection: direction) return range.withLowerBound(lowerBound) case (.word, .backward): + lineNavigationLocationService.reset() let upperBound = wordNavigationLocationService.location(movingFrom: range.upperBound, inDirection: direction) return range.withUpperBound(upperBound) case (.word, .forward): + lineNavigationLocationService.reset() let lowerBound = wordNavigationLocationService.location(movingFrom: range.lowerBound, inDirection: direction) return range.withLowerBound(lowerBound) case (.line, .backward): @@ -56,13 +69,29 @@ final class SelectionService { } func range(moving range: NSRange, toBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> NSRange { + lineNavigationLocationService.reset() if range.length == 0 { selectionOrigin = range.location } - return range + let anchoringDirection = anchoringDirection(moving: range, inDirection: direction) + switch anchoringDirection { + case .backward: + if let upperBound = stringTokenizer.location(from: range.upperBound, toBoundary: boundary, inDirection: direction) { + return range.withUpperBound(upperBound) + } else { + return range + } + case .forward: + if let lowerBound = stringTokenizer.location(from: range.lowerBound, toBoundary: boundary, inDirection: direction) { + return range.withLowerBound(lowerBound) + } else { + return range + } + } } func rangeByStartDraggingSelection(from location: Int) -> NSRange { + lineNavigationLocationService.reset() let range = NSRange(location: location, length: 0) selectionOrigin = location return range From 678ccb9581b868d6005434700ae0e19aab029dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 9 Feb 2023 08:13:31 +0100 Subject: [PATCH 137/232] Makes lineControllerStorage private --- .../ConsiderateLineNavigationLocationFactory.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift index 95ed4733b..1e0fddafe 100644 --- a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift +++ b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift @@ -31,8 +31,8 @@ final class ConsiderateLineNavigationLocationFactory { } var lineManager: LineManager - var lineControllerStorage: LineControllerStorage + private let lineControllerStorage: LineControllerStorage private var previousOperation: MoveOperation? private var lineNavigationLocationFactory: LineNavigationLocationFactory { LineNavigationLocationFactory(lineManager: lineManager, lineControllerStorage: lineControllerStorage) From 956eb9dda0a655be4b791780927b99a7f3865ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 9 Feb 2023 08:13:36 +0100 Subject: [PATCH 138/232] Adds default offset --- .../ConsiderateLineNavigationLocationFactory.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift index 1e0fddafe..6d6589076 100644 --- a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift +++ b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift @@ -43,7 +43,7 @@ final class ConsiderateLineNavigationLocationFactory { self.lineControllerStorage = lineControllerStorage } - func location(movingFrom location: Int, byLineCount offset: Int, inDirection direction: TextDirection) -> Int { + func location(movingFrom location: Int, byLineCount offset: Int = 1, inDirection direction: TextDirection) -> Int { if let previousOperation { let directionedOffset = DirectionedOffset(offset: offset, inDirection: direction) let newDirectionedOffset = previousOperation.offset + directionedOffset From 5f1c0a478925ce5a6038fe64f68d3e78560bddd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 9 Feb 2023 08:14:57 +0100 Subject: [PATCH 139/232] Renames ConsiderateLineNavigationLocationFactory to StatefulLineNavigationLocationFactory --- ...ry.swift => StatefulLineNavigationLocationFactory.swift} | 6 +++--- .../Runestone/TextView/Navigation/NavigationService.swift | 4 ++-- .../Runestone/TextView/Navigation/SelectionService.swift | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) rename Sources/Runestone/TextView/Navigation/NavigationLocationFactories/{ConsiderateLineNavigationLocationFactory.swift => StatefulLineNavigationLocationFactory.swift} (94%) diff --git a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift similarity index 94% rename from Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift rename to Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift index 6d6589076..431a48707 100644 --- a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/ConsiderateLineNavigationLocationFactory.swift +++ b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift @@ -1,6 +1,6 @@ import Foundation -final class ConsiderateLineNavigationLocationFactory { +final class StatefulLineNavigationLocationFactory { private struct MoveOperation { let location: Int let offset: DirectionedOffset @@ -68,7 +68,7 @@ final class ConsiderateLineNavigationLocationFactory { } } -private extension ConsiderateLineNavigationLocationFactory { +private extension StatefulLineNavigationLocationFactory { private func operation(movingFrom location: Int, byLineCount offset: Int, inDirection direction: TextDirection) -> MoveOperation { let directionedOffset = DirectionedOffset(offset: offset, inDirection: direction) let destinationLocation = lineNavigationLocationFactory.location(movingFrom: location, byLineCount: offset, inDirection: direction) @@ -76,7 +76,7 @@ private extension ConsiderateLineNavigationLocationFactory { } } -extension ConsiderateLineNavigationLocationFactory.DirectionedOffset { +extension StatefulLineNavigationLocationFactory.DirectionedOffset { static func +(lhs: Self, rhs: Self) -> Self { Self(rawValue: lhs.rawValue + rhs.rawValue) } diff --git a/Sources/Runestone/TextView/Navigation/NavigationService.swift b/Sources/Runestone/TextView/Navigation/NavigationService.swift index 34221aa8a..01184f23f 100644 --- a/Sources/Runestone/TextView/Navigation/NavigationService.swift +++ b/Sources/Runestone/TextView/Navigation/NavigationService.swift @@ -20,13 +20,13 @@ final class NavigationService { private var wordNavigationLocationService: WordNavigationLocationFactory { WordNavigationLocationFactory(stringTokenizer: stringTokenizer) } - private var lineNavigationLocationService: ConsiderateLineNavigationLocationFactory + private var lineNavigationLocationService: StatefulLineNavigationLocationFactory init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { self.stringView = stringView self.lineManager = lineManager self.lineControllerStorage = lineControllerStorage - self.lineNavigationLocationService = ConsiderateLineNavigationLocationFactory( + self.lineNavigationLocationService = StatefulLineNavigationLocationFactory( lineManager: lineManager, lineControllerStorage: lineControllerStorage ) diff --git a/Sources/Runestone/TextView/Navigation/SelectionService.swift b/Sources/Runestone/TextView/Navigation/SelectionService.swift index 3666c5e04..58244cd1d 100644 --- a/Sources/Runestone/TextView/Navigation/SelectionService.swift +++ b/Sources/Runestone/TextView/Navigation/SelectionService.swift @@ -24,13 +24,13 @@ final class SelectionService { private var wordNavigationLocationService: WordNavigationLocationFactory { WordNavigationLocationFactory(stringTokenizer: stringTokenizer) } - private let lineNavigationLocationService: ConsiderateLineNavigationLocationFactory + private let lineNavigationLocationService: StatefulLineNavigationLocationFactory init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { self.stringView = stringView self.lineManager = lineManager self.lineControllerStorage = lineControllerStorage - self.lineNavigationLocationService = ConsiderateLineNavigationLocationFactory( + self.lineNavigationLocationService = StatefulLineNavigationLocationFactory( lineManager: lineManager, lineControllerStorage: lineControllerStorage ) From f507bf91e04e6ecbeb86135af08034338d929ef8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 9 Feb 2023 08:53:32 +0100 Subject: [PATCH 140/232] Fixes SwiftLint warnings --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 2 +- .../StatefulLineNavigationLocationFactory.swift | 2 +- Sources/Runestone/TextView/Navigation/StringTokenizer.swift | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 2a16c2fab..385ae6dfb 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -540,7 +540,7 @@ open class TextView: NSView { } } - open override func resetCursorRects() { + override public func resetCursorRects() { super.resetCursorRects() addCursorRect(bounds, cursor: .iBeam) } diff --git a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift index 431a48707..e549222ef 100644 --- a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift +++ b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift @@ -77,7 +77,7 @@ private extension StatefulLineNavigationLocationFactory { } extension StatefulLineNavigationLocationFactory.DirectionedOffset { - static func +(lhs: Self, rhs: Self) -> Self { + static func + (lhs: Self, rhs: Self) -> Self { Self(rawValue: lhs.rawValue + rhs.rawValue) } } diff --git a/Sources/Runestone/TextView/Navigation/StringTokenizer.swift b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift index c5b175a2b..d3fe0b7b2 100644 --- a/Sources/Runestone/TextView/Navigation/StringTokenizer.swift +++ b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift @@ -187,7 +187,6 @@ private extension StringTokenizer { } } - // swiftlint:disable:next cyclomatic_complexity private func location(from location: Int, toWordBoundaryInDirection direction: TextDirection) -> Int? { func advanceIndex(_ index: Int) -> Int { let preferredIndex: Int From 2e93d3564ffb05de9ca766ac23141f7e313a4d69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Feb 2023 15:47:44 +0100 Subject: [PATCH 141/232] Adds double and triple click to select words and lines --- .../Library/CharacterSet+Helpers.swift | 12 ++ .../TextView/Core/Mac/TextView_Mac.swift | 8 +- .../TextViewController+Selection.swift | 8 ++ .../Navigation/SelectionService.swift | 104 ++++++++++++++++++ .../TextView/Navigation/StringTokenizer.swift | 11 -- 5 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 Sources/Runestone/Library/CharacterSet+Helpers.swift diff --git a/Sources/Runestone/Library/CharacterSet+Helpers.swift b/Sources/Runestone/Library/CharacterSet+Helpers.swift new file mode 100644 index 000000000..55af5f856 --- /dev/null +++ b/Sources/Runestone/Library/CharacterSet+Helpers.swift @@ -0,0 +1,12 @@ +import Foundation + +extension CharacterSet { + func containsAllCharacters(of string: String) -> Bool { + var containsAllCharacters = true + for char in string.unicodeScalars where !contains(char) { + containsAllCharacters = false + break + } + return containsAllCharacters + } +} diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 385ae6dfb..2acbaba4d 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -520,9 +520,13 @@ open class TextView: NSView { override public func mouseDown(with event: NSEvent) { super.mouseDown(with: event) - if let location = locationClosestToPoint(in: event) { + if event.clickCount == 1, let location = locationClosestToPoint(in: event) { textViewController.move(to: location) textViewController.startDraggingSelection(from: location) + } else if event.clickCount == 2, let location = locationClosestToPoint(in: event) { + textViewController.selectWord(at: location) + } else if event.clickCount == 3, let location = locationClosestToPoint(in: event) { + textViewController.selectLine(at: location) } } @@ -535,7 +539,7 @@ open class TextView: NSView { override public func mouseUp(with event: NSEvent) { super.mouseUp(with: event) - if let location = locationClosestToPoint(in: event) { + if event.clickCount == 1, let location = locationClosestToPoint(in: event) { textViewController.extendDraggedSelection(to: location) } } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift index 4c27e54ba..05b815f71 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift @@ -67,6 +67,14 @@ extension TextViewController { func extendDraggedSelection(to location: Int) { selectedRange = selectionService.rangeByExtendingDraggedSelection(to: location) } + + func selectWord(at location: Int) { + selectedRange = selectionService.rangeBySelectingWord(at: location) + } + + func selectLine(at location: Int) { + selectedRange = selectionService.rangeBySelectingLine(at: location) + } } private extension TextViewController { diff --git a/Sources/Runestone/TextView/Navigation/SelectionService.swift b/Sources/Runestone/TextView/Navigation/SelectionService.swift index 58244cd1d..876911cb8 100644 --- a/Sources/Runestone/TextView/Navigation/SelectionService.swift +++ b/Sources/Runestone/TextView/Navigation/SelectionService.swift @@ -11,6 +11,20 @@ final class SelectionService { } } + private struct BracketPair { + let opening: String + let closing: String + + func component(inDirection direction: TextDirection) -> String { + switch direction { + case .backward: + return opening + case .forward: + return closing + } + } + } + private let lineControllerStorage: LineControllerStorage private var anchoringDirection: TextDirection? private var selectionOrigin: Int? @@ -105,6 +119,48 @@ final class SelectionService { let upperBound = max(selectionOrigin, location) return NSRange(location: lowerBound, length: upperBound - lowerBound) } + + func rangeBySelectingWord(at location: Int) -> NSRange { + let character = stringView.string.character(at: location) + let substringRange = stringView.string.customRangeOfComposedCharacterSequence(at: location) + let substring = stringView.string.substring(with: substringRange) + let selectableSymbols = [Symbol.carriageReturnLineFeed, Symbol.carriageReturn, Symbol.lineFeed] + let bracketPairs = [ + BracketPair(opening: "(", closing: ")"), + BracketPair(opening: "{", closing: "}"), + BracketPair(opening: "[", closing: "]") + ] + if let scalar = Unicode.Scalar(character), CharacterSet.whitespaces.contains(scalar) { + return rangeOfWhitespace(matching: character, at: location) + } else if CharacterSet.alphanumerics.containsAllCharacters(of: substring) { + let lowerBound = stringTokenizer.location(from: location, toBoundary: .word, inDirection: .backward) ?? location + let upperBound = stringTokenizer.location(from: location, toBoundary: .word, inDirection: .forward) ?? location + return NSRange(location: lowerBound, length: upperBound - lowerBound) + } else if let selectableSymbol = selectableSymbols.first(where: { $0 == substring }) { + return NSRange(location: location, length: selectableSymbol.count) + } else if let bracketPair = bracketPairs.first(where: { $0.opening == substring }) { + return range(enclosing: bracketPair, inDirection: .forward, startingAt: location) + } else if let bracketPair = bracketPairs.first(where: { $0.closing == substring }) { + return range(enclosing: bracketPair, inDirection: .backward, startingAt: location) + } else { + return NSRange(location: location, length: 1) + } + } + + func rangeBySelectingLine(at location: Int) -> NSRange { + guard let line = lineManager.line(containingCharacterAt: location) else { + return NSRange(location: location, length: 0) + } + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + let lineLocalLocation = location - line.location + guard let lineFragment = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { + return NSRange(location: location, length: 0) + } + guard let range = lineFragment.data.lineFragment?.range else { + return NSRange(location: location, length: 0) + } + return NSRange(location: line.location + range.location, length: range.length) + } } private extension SelectionService { @@ -117,6 +173,54 @@ private extension SelectionService { return .backward } } + + private func rangeOfWhitespace(matching character: unichar, at location: Int) -> NSRange { + var lowerBound = location + var upperBound = location + 1 + while lowerBound > 0 && lowerBound < stringView.string.length && stringView.string.character(at: lowerBound - 1) == character { + lowerBound -= 1 + } + while upperBound >= 0 && upperBound < stringView.string.length && stringView.string.character(at: upperBound) == character { + upperBound += 1 + } + return NSRange(location: lowerBound, length: upperBound - lowerBound) + } + + private func range(enclosing characterPair: BracketPair, inDirection direction: TextDirection, startingAt location: Int) -> NSRange { + func advanceLocation(_ location: Int) -> Int { + switch direction { + case .forward: + return location + 1 + case .backward: + return location - 1 + } + } + // Keep track of how many unclosed brackets we have. Whenever we reach zero we have found our end location. + var unclosedBracketsCount = 1 + var endLocation = advanceLocation(location) + // In this case an "opening" component can actually be a closing component, e.g. "}", if that's what the user double clicked. That closing bracket "opens" our selection and we need to find the needle component, e.g. "{". + let openingComponent = characterPair.component(inDirection: direction.opposite) + let needleComponent = characterPair.component(inDirection: direction) + while endLocation > 0 && endLocation < stringView.string.length && unclosedBracketsCount > 0 { + let characterRange = NSRange(location: endLocation, length: 1) + let substring = stringView.string.substring(with: characterRange) + if substring == openingComponent { + unclosedBracketsCount += 1 + } + if substring == needleComponent { + unclosedBracketsCount -= 1 + } + endLocation = advanceLocation(endLocation) + } + var lowerBound = min(location, endLocation) + var upperBound = max(location, endLocation) + // Offset the range by one if we are searching backwards as we want to select the character on the input location. + if direction == .backward { + lowerBound += 1 + upperBound += 1 + } + return NSRange(location: lowerBound, length: upperBound - lowerBound) + } } private extension NSRange { diff --git a/Sources/Runestone/TextView/Navigation/StringTokenizer.swift b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift index d3fe0b7b2..2bcd45fee 100644 --- a/Sources/Runestone/TextView/Navigation/StringTokenizer.swift +++ b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift @@ -245,14 +245,3 @@ private extension StringView { return substring(in: range) } } - -private extension CharacterSet { - func containsAllCharacters(of string: String) -> Bool { - var containsAllCharacters = true - for char in string.unicodeScalars where !contains(char) { - containsAllCharacters = false - break - } - return containsAllCharacters - } -} From 94ef5257d1e60231c502c92aedf55eb1bf99e81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Feb 2023 16:02:02 +0100 Subject: [PATCH 142/232] Validates menu items --- .../TextView/Core/Mac/TextView_Mac.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 2acbaba4d..d094e2aea 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -1,9 +1,10 @@ // swiftlint:disable file_length #if os(macOS) import AppKit +import UniformTypeIdentifiers // swiftlint:disable:next type_body_length -open class TextView: NSView { +open class TextView: NSView, NSMenuItemValidation { public weak var editorDelegate: TextViewDelegate? override public var acceptsFirstResponder: Bool { true @@ -562,6 +563,18 @@ open class TextView: NSView { public func setState(_ state: TextViewState, addUndoAction: Bool = false) { textViewController.setState(state, addUndoAction: addUndoAction) } + + public func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + if menuItem.action == #selector(copy(_:)) || menuItem.action == #selector(cut(_:)){ + return selectedRange().length > 0 + } else if menuItem.action == #selector(paste(_:)) { + return NSPasteboard.general.canReadItem(withDataConformingToTypes: [UTType.plainText.identifier]) + } else if menuItem.action == #selector(selectAll(_:)) { + return text.count > 0 + } else { + return true + } + } } // MARK: - Commands From deb8493159097775c4812d288a2ea93f4b81ad2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Feb 2023 16:08:25 +0100 Subject: [PATCH 143/232] Fixes SwiftGen input --- swiftgen.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/swiftgen.yml b/swiftgen.yml index c4135dec5..28ffe79df 100644 --- a/swiftgen.yml +++ b/swiftgen.yml @@ -1,6 +1,5 @@ strings: - - inputs: - - Sources/Runestone/Resources + - inputs: Sources/Runestone/Resources/en.lproj/Localizable.strings outputs: templateName: structured-swift5 output: Sources/Runestone/Library/L10n.swift From c693d7f2df549f1e95e4300f7e8d306a962cb42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Feb 2023 16:19:54 +0100 Subject: [PATCH 144/232] Adds right-click menu --- Sources/Runestone/Library/L10n.swift | 24 +++++++++++-------- .../Resources/de.lproj/Localizable.strings | 3 +++ .../Resources/en.lproj/Localizable.strings | 3 +++ .../Resources/es.lproj/Localizable.strings | 3 +++ .../Resources/fi.lproj/Localizable.strings | 5 +++- .../Resources/fr.lproj/Localizable.strings | 3 +++ .../Resources/ja.lproj/Localizable.strings | 3 +++ .../TextView/Core/Mac/TextView_Mac.swift | 20 ++++++++++++++++ .../Navigation/SelectionService.swift | 3 +++ 9 files changed, 56 insertions(+), 11 deletions(-) diff --git a/Sources/Runestone/Library/L10n.swift b/Sources/Runestone/Library/L10n.swift index ee541710a..9de89f01b 100644 --- a/Sources/Runestone/Library/L10n.swift +++ b/Sources/Runestone/Library/L10n.swift @@ -3,31 +3,35 @@ import Foundation -// swiftlint:disable superfluous_disable_command file_length implicit_return +// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references // MARK: - Strings // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces internal enum L10n { - internal enum Menu { internal enum ItemTitle { + /// Paste + internal static let copy = L10n.tr("Localizable", "menu.item_title.copy", fallback: "Paste") + /// Cut + internal static let cut = L10n.tr("Localizable", "menu.item_title.cut", fallback: "Cut") + /// Copy + internal static let paste = L10n.tr("Localizable", "menu.item_title.paste", fallback: "Copy") /// Replace - internal static let replace = L10n.tr("Localizable", "menu.item_title.replace") + internal static let replace = L10n.tr("Localizable", "menu.item_title.replace", fallback: "Replace") } } - internal enum Undo { internal enum ActionName { /// Move Lines Down - internal static let moveLinesDown = L10n.tr("Localizable", "undo.action_name.move_lines_down") + internal static let moveLinesDown = L10n.tr("Localizable", "undo.action_name.move_lines_down", fallback: "Move Lines Down") /// Move Lines Up - internal static let moveLinesUp = L10n.tr("Localizable", "undo.action_name.move_lines_up") + internal static let moveLinesUp = L10n.tr("Localizable", "undo.action_name.move_lines_up", fallback: "Move Lines Up") /// Replace All - internal static let replaceAll = L10n.tr("Localizable", "undo.action_name.replace_all") + internal static let replaceAll = L10n.tr("Localizable", "undo.action_name.replace_all", fallback: "Replace All") /// Typing - internal static let typing = L10n.tr("Localizable", "undo.action_name.typing") + internal static let typing = L10n.tr("Localizable", "undo.action_name.typing", fallback: "Typing") } } } @@ -37,8 +41,8 @@ internal enum L10n { // MARK: - Implementation Details extension L10n { - private static func tr(_ table: String, _ key: String, _ args: CVarArg...) -> String { - let format = BundleToken.bundle.localizedString(forKey: key, value: nil, table: table) + private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { + let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) return String(format: format, locale: Locale.current, arguments: args) } } diff --git a/Sources/Runestone/Resources/de.lproj/Localizable.strings b/Sources/Runestone/Resources/de.lproj/Localizable.strings index 02f596efd..00886a9dc 100644 --- a/Sources/Runestone/Resources/de.lproj/Localizable.strings +++ b/Sources/Runestone/Resources/de.lproj/Localizable.strings @@ -2,4 +2,7 @@ "undo.action_name.replace_all" = "Alles Ersetzen"; "undo.action_name.move_lines_up" = "Zeilen nach oben verschieben"; "undo.action_name.move_lines_down" = "Zeilen nach unten verschieben"; +"menu.item_title.cut" = "Cut"; +"menu.item_title.copy" = "Copy"; +"menu.item_title.paste" = "Paste"; "menu.item_title.replace" = "Ersetzen"; diff --git a/Sources/Runestone/Resources/en.lproj/Localizable.strings b/Sources/Runestone/Resources/en.lproj/Localizable.strings index 326c6f42a..67594e597 100644 --- a/Sources/Runestone/Resources/en.lproj/Localizable.strings +++ b/Sources/Runestone/Resources/en.lproj/Localizable.strings @@ -2,4 +2,7 @@ "undo.action_name.replace_all" = "Replace All"; "undo.action_name.move_lines_up" = "Move Lines Up"; "undo.action_name.move_lines_down" = "Move Lines Down"; +"menu.item_title.cut" = "Cut"; +"menu.item_title.copy" = "Copy"; +"menu.item_title.paste" = "Paste"; "menu.item_title.replace" = "Replace"; diff --git a/Sources/Runestone/Resources/es.lproj/Localizable.strings b/Sources/Runestone/Resources/es.lproj/Localizable.strings index 6615438d0..b062a36ef 100644 --- a/Sources/Runestone/Resources/es.lproj/Localizable.strings +++ b/Sources/Runestone/Resources/es.lproj/Localizable.strings @@ -2,4 +2,7 @@ "undo.action_name.replace_all" = "Reemplazar todo"; "undo.action_name.move_lines_up" = "Mover líneas hacia arriba"; "undo.action_name.move_lines_down" = "Mover líneas hacia abajo"; +"menu.item_title.cut" = "Cut"; +"menu.item_title.copy" = "Copy"; +"menu.item_title.paste" = "Paste"; "menu.item_title.replace" = "Reemplazar"; diff --git a/Sources/Runestone/Resources/fi.lproj/Localizable.strings b/Sources/Runestone/Resources/fi.lproj/Localizable.strings index c99ae0016..65f1b687f 100644 --- a/Sources/Runestone/Resources/fi.lproj/Localizable.strings +++ b/Sources/Runestone/Resources/fi.lproj/Localizable.strings @@ -2,4 +2,7 @@ "undo.action_name.replace_all" = "Korvaa kaikki"; "undo.action_name.move_lines_up" = "Siirrä rivit ylöspäin"; "undo.action_name.move_lines_down" = "Siirrä rivejä alaspäin"; -"menu.item_title.replace" = "Korvaa"; \ No newline at end of file +"menu.item_title.cut" = "Cut"; +"menu.item_title.copy" = "Copy"; +"menu.item_title.paste" = "Paste"; +"menu.item_title.replace" = "Korvaa"; diff --git a/Sources/Runestone/Resources/fr.lproj/Localizable.strings b/Sources/Runestone/Resources/fr.lproj/Localizable.strings index 718124bdf..832e0bfff 100644 --- a/Sources/Runestone/Resources/fr.lproj/Localizable.strings +++ b/Sources/Runestone/Resources/fr.lproj/Localizable.strings @@ -2,4 +2,7 @@ "undo.action_name.replace_all" = "Remplacer tout"; "undo.action_name.move_lines_up" = "Déplacer les lignes vers le haut"; "undo.action_name.move_lines_down" = "Déplacer les lignes vers le bas"; +"menu.item_title.cut" = "Cut"; +"menu.item_title.copy" = "Copy"; +"menu.item_title.paste" = "Paste"; "menu.item_title.replace" = "Remplacer"; diff --git a/Sources/Runestone/Resources/ja.lproj/Localizable.strings b/Sources/Runestone/Resources/ja.lproj/Localizable.strings index 1d2fa51ce..8f670eb7c 100644 --- a/Sources/Runestone/Resources/ja.lproj/Localizable.strings +++ b/Sources/Runestone/Resources/ja.lproj/Localizable.strings @@ -2,4 +2,7 @@ "undo.action_name.replace_all" = "すべて置き換え"; "undo.action_name.move_lines_up" = "行を上に移動"; "undo.action_name.move_lines_down" = "行を下に移動"; +"menu.item_title.cut" = "Cut"; +"menu.item_title.copy" = "Copy"; +"menu.item_title.paste" = "Paste"; "menu.item_title.replace" = "置換"; diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index d094e2aea..a9936b2d4 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -450,6 +450,7 @@ open class TextView: NSView, NSMenuItemValidation { setNeedsLayout() setupWindowObservers() setupScrollViewBoundsDidChangeObserver() + setupMenu() } public required init?(coder: NSCoder) { @@ -545,6 +546,15 @@ open class TextView: NSView, NSMenuItemValidation { } } + open override func rightMouseDown(with event: NSEvent) { + if event.clickCount == 1, let location = locationClosestToPoint(in: event) { + if let selectedRange = textViewController.selectedRange, !selectedRange.contains(location) || textViewController.selectedRange == nil { + textViewController.selectWord(at: location) + } + } + super.rightMouseDown(with: event) + } + override public func resetCursorRects() { super.resetCursorRects() addCursorRect(bounds, cursor: .iBeam) @@ -920,6 +930,16 @@ private extension TextView { } } +// MARK: - Menu +private extension TextView { + private func setupMenu() { + menu = NSMenu() + menu?.addItem(withTitle: L10n.Menu.ItemTitle.cut, action: #selector(cut(_:)), keyEquivalent: "") + menu?.addItem(withTitle: L10n.Menu.ItemTitle.copy, action: #selector(copy(_:)), keyEquivalent: "") + menu?.addItem(withTitle: L10n.Menu.ItemTitle.paste, action: #selector(paste(_:)), keyEquivalent: "") + } +} + // MARK: - TextViewControllerDelegate extension TextView: TextViewControllerDelegate { func textViewControllerDidChangeText(_ textViewController: TextViewController) { diff --git a/Sources/Runestone/TextView/Navigation/SelectionService.swift b/Sources/Runestone/TextView/Navigation/SelectionService.swift index 876911cb8..7e53d5a92 100644 --- a/Sources/Runestone/TextView/Navigation/SelectionService.swift +++ b/Sources/Runestone/TextView/Navigation/SelectionService.swift @@ -121,6 +121,9 @@ final class SelectionService { } func rangeBySelectingWord(at location: Int) -> NSRange { + guard location >= 0 && location < stringView.string.length else { + return NSRange(location: location, length: 0) + } let character = stringView.string.character(at: location) let substringRange = stringView.string.customRangeOfComposedCharacterSequence(at: location) let substring = stringView.string.substring(with: substringRange) From 55d21a5e6743acd1baca6a7b3424f5b4f6870637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 11 Feb 2023 16:36:13 +0100 Subject: [PATCH 145/232] Adds support for undo/redo --- .../TextView/Core/Mac/TextView_Mac.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index a9936b2d4..1cb6037f2 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -581,6 +581,10 @@ open class TextView: NSView, NSMenuItemValidation { return NSPasteboard.general.canReadItem(withDataConformingToTypes: [UTType.plainText.identifier]) } else if menuItem.action == #selector(selectAll(_:)) { return text.count > 0 + } else if menuItem.action == #selector(undo(_:)) { + return undoManager?.canUndo ?? false + } else if menuItem.action == #selector(redo(_:)) { + return undoManager?.canRedo ?? false } else { return true } @@ -797,6 +801,18 @@ public extension TextView { override func selectAll(_ sender: Any?) { textViewController.selectedRange = NSRange(location: 0, length: textViewController.stringView.string.length) } + + @objc func undo(_ sender: Any?) { + if let undoManager = undoManager, undoManager.canUndo { + undoManager.undo() + } + } + + @objc func redo(_ sender: Any?) { + if let undoManager = undoManager, undoManager.canRedo { + undoManager.redo() + } + } } // MARK: - Window From 4c8043b72f5a38b12e33cf0e37d011e5a2509172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 12 Feb 2023 13:14:19 +0100 Subject: [PATCH 146/232] Fixes compile issues on Mac --- .../TextView/Core/TextViewController/TextViewController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 8b31fd46d..3e62a4d05 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -149,7 +149,9 @@ final class TextViewController { layoutManager.stringView = stringView indentController.stringView = stringView navigationService.stringView = stringView + #if os(macOS) selectionService.stringView = stringView + #endif } } } @@ -162,7 +164,9 @@ final class TextViewController { contentSizeService.lineManager = lineManager highlightService.lineManager = lineManager navigationService.lineManager = lineManager + #if os(macOS) selectionService.lineManager = lineManager + #endif } } } From 524c21bcc44c387dbc99977f772ebf14ca920160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 16 Feb 2023 08:08:40 +0100 Subject: [PATCH 147/232] Enlarges window --- Example/MacExample/Base.lproj/Main.storyboard | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Example/MacExample/Base.lproj/Main.storyboard b/Example/MacExample/Base.lproj/Main.storyboard index 36cc0b05d..04f4875d2 100644 --- a/Example/MacExample/Base.lproj/Main.storyboard +++ b/Example/MacExample/Base.lproj/Main.storyboard @@ -700,20 +700,20 @@ - + - + - + From 59e5c6b95741064f88036fba1e5323074a2a4942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 16 Feb 2023 08:31:16 +0100 Subject: [PATCH 148/232] Fixes lines being rehighlighted --- .../TextView/Core/ContentSizeService.swift | 4 +++- .../TextView/Core/LayoutManager.swift | 4 +++- .../TextViewController.swift | 4 +++- .../LineController/LineController.swift | 19 +++++++++---------- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/Sources/Runestone/TextView/Core/ContentSizeService.swift b/Sources/Runestone/TextView/Core/ContentSizeService.swift index 8d3c20a35..a35425295 100644 --- a/Sources/Runestone/TextView/Core/ContentSizeService.swift +++ b/Sources/Runestone/TextView/Core/ContentSizeService.swift @@ -188,7 +188,9 @@ private extension ContentSizeService { } lineIDTrackingWidth = longestLine.id let lineController = lineControllerStorage.getOrCreateLineController(for: longestLine) - lineController.invalidateEverything() + lineController.invalidateString() + lineController.invalidateTypesetting() + lineController.invalidateSyntaxHighlighting() lineWidths[longestLine.id] = lineController.lineWidth if !isLineWrappingEnabled { _longestLineWidth = nil diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index 4835cc09d..12727e0b5 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -183,7 +183,9 @@ final class LayoutManager { func redisplayLines(withIDs lineIDs: Set) { for lineID in lineIDs { if let lineController = lineControllerStorage[lineID] { - lineController.invalidateEverything() + lineController.invalidateString() + lineController.invalidateTypesetting() + lineController.invalidateSyntaxHighlighting() // 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) { let lineYPosition = lineController.line.yPosition diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 3e62a4d05..0cd599068 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -127,7 +127,9 @@ final class TextViewController { contentSizeService.scrollViewSize = scrollViewSize layoutManager.scrollViewWidth = scrollViewSize.width if isLineWrappingEnabled { - invalidateLines() + for lineController in lineControllerStorage { + lineController.invalidateTypesetting() + } } } } diff --git a/Sources/Runestone/TextView/LineController/LineController.swift b/Sources/Runestone/TextView/LineController/LineController.swift index 6b5b640df..0ded4c05b 100644 --- a/Sources/Runestone/TextView/LineController/LineController.swift +++ b/Sources/Runestone/TextView/LineController/LineController.swift @@ -140,24 +140,23 @@ final class LineController { syntaxHighlighter?.cancel() } - func invalidateEverything() { - isLineFragmentCacheInvalid = true + func invalidateString() { isStringInvalid = true - isTypesetterInvalid = true - isDefaultAttributesInvalid = true - isSyntaxHighlightingInvalid = true - _lineHeight = nil } - func invalidateSyntaxHighlighter() { - cachedSyntaxHighlighter = nil + func invalidateTypesetting() { + isLineFragmentCacheInvalid = true + isTypesetterInvalid = true + _lineHeight = nil } func invalidateSyntaxHighlighting() { - isTypesetterInvalid = true isDefaultAttributesInvalid = true isSyntaxHighlightingInvalid = true - _lineHeight = nil + } + + func invalidateSyntaxHighlighter() { + cachedSyntaxHighlighter = nil } func lineFragmentControllers(in rect: CGRect) -> [LineFragmentController] { From 4b567fab2e239d2b067f5681682d6c750d887352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Thu, 16 Feb 2023 15:23:37 +0100 Subject: [PATCH 149/232] Fixes SwiftLint warnings --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 1cb6037f2..0c7e92630 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -546,7 +546,7 @@ open class TextView: NSView, NSMenuItemValidation { } } - open override func rightMouseDown(with event: NSEvent) { + override public func rightMouseDown(with event: NSEvent) { if event.clickCount == 1, let location = locationClosestToPoint(in: event) { if let selectedRange = textViewController.selectedRange, !selectedRange.contains(location) || textViewController.selectedRange == nil { textViewController.selectWord(at: location) @@ -575,12 +575,12 @@ open class TextView: NSView, NSMenuItemValidation { } public func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { - if menuItem.action == #selector(copy(_:)) || menuItem.action == #selector(cut(_:)){ + if menuItem.action == #selector(copy(_:)) || menuItem.action == #selector(cut(_:)) { return selectedRange().length > 0 } else if menuItem.action == #selector(paste(_:)) { return NSPasteboard.general.canReadItem(withDataConformingToTypes: [UTType.plainText.identifier]) } else if menuItem.action == #selector(selectAll(_:)) { - return text.count > 0 + return !text.isEmpty } else if menuItem.action == #selector(undo(_:)) { return undoManager?.canUndo ?? false } else if menuItem.action == #selector(redo(_:)) { From 6c68c226a3aad6775c15719847fd4b50ba38f8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20St=C3=BChrk?= Date: Fri, 17 Feb 2023 09:57:26 +0100 Subject: [PATCH 150/232] Infrastructure for testing key bindings. (#270) --- .../Helpers/NSEvent+create.swift | 230 ++++++++++++++++++ .../TextView/TextViewMacTests.swift | 44 ++++ 2 files changed, 274 insertions(+) create mode 100644 Tests/RunestoneTests/Helpers/NSEvent+create.swift create mode 100644 Tests/RunestoneTests/TextView/TextViewMacTests.swift diff --git a/Tests/RunestoneTests/Helpers/NSEvent+create.swift b/Tests/RunestoneTests/Helpers/NSEvent+create.swift new file mode 100644 index 000000000..bb2cff370 --- /dev/null +++ b/Tests/RunestoneTests/Helpers/NSEvent+create.swift @@ -0,0 +1,230 @@ +#if os(macOS) +import AppKit +import Carbon + +extension NSEvent { + /// Creates an event which can be used in tests. + /// It simulates a key down event for the given ASCII character. + static func create(characters: String, modifiers: NSEvent.ModifierFlags) throws -> NSEvent { + guard let keyCode = keyMapping[characters] else { throw SetupError.unknownCharacters(characters) } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: CFTimeInterval(), + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) else { throw SetupError.eventCreationError } + + return event + } + + /// Creates an event which can be used in tests. + /// It simulates a key down event for the given device-independent key. + static func create(key: KeyboardIndependentKeys, modifiers: NSEvent.ModifierFlags = []) throws -> NSEvent { + let keyCode = CGKeyCode(UInt16(key.keyCode)) + guard + let cgEvent = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true), + let nsEvent = NSEvent(cgEvent: cgEvent) + else { throw SetupError.eventCreationError } + + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: CFTimeInterval(), + windowNumber: 0, + context: nil, + characters: nsEvent.characters ?? "", + charactersIgnoringModifiers: nsEvent.charactersIgnoringModifiers ?? "", + isARepeat: false, + keyCode: keyCode + ) else { throw SetupError.eventCreationError } + + return event + } +} + +/// A mapping where the mapping's key is an ASCII character and the value is the key code for the character based on current keyboard. +/// This is used to translate keyboard-dependent characters into the correct keyboard. +private let keyMapping: [String: UInt16] = { + var mapping: [String: UInt16] = [:] + for keyCode in (0..<128) { + guard let cgevent = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(keyCode), keyDown: true) else { continue } + guard let nsevent = NSEvent(cgEvent: cgevent) else { continue } + + guard nsevent.type == .keyDown, + nsevent.specialKey == nil, + let characters = nsevent.charactersIgnoringModifiers, + !characters.isEmpty + else { continue } + mapping[characters] = UInt16(keyCode) + } + + return mapping +}() + +/// All keys which are independent from the keyboard, so they have the same key code on all keyboards. +enum KeyboardIndependentKeys { + case `return` + case tab + case space + case delete + case escape + case command + case shift + case capsLock + case option + case control + case rightCommand + case rightShift + case rightOption + case rightControl + case function + case volumeUp + case volumeDown + case mute + case f1 + case f2 + case f3 + case f4 + case f5 + case f6 + case f7 + case f8 + case f9 + case f10 + case f11 + case f12 + case f13 + case f14 + case f15 + case f16 + case f17 + case f18 + case f19 + case f20 + case help + case home + case pageUp + case forwardDelete + case end + case pageDown + case leftArrow + case rightArrow + case downArrow + case upArrow + + var keyCode: Int { + switch self { + case .`return`: + return kVK_Return + case .tab: + return kVK_Tab + case .space: + return kVK_Space + case .delete: + return kVK_Delete + case .escape: + return kVK_Escape + case .command: + return kVK_Command + case .shift: + return kVK_Shift + case .capsLock: + return kVK_CapsLock + case .option: + return kVK_Option + case .control: + return kVK_Control + case .rightCommand: + return kVK_RightCommand + case .rightShift: + return kVK_RightShift + case .rightOption: + return kVK_RightOption + case .rightControl: + return kVK_RightControl + case .function: + return kVK_Function + case .volumeUp: + return kVK_VolumeUp + case .volumeDown: + return kVK_VolumeDown + case .mute: + return kVK_Mute + case .f1: + return kVK_F1 + case .f2: + return kVK_F2 + case .f3: + return kVK_F3 + case .f4: + return kVK_F4 + case .f5: + return kVK_F5 + case .f6: + return kVK_F6 + case .f7: + return kVK_F7 + case .f8: + return kVK_F8 + case .f9: + return kVK_F9 + case .f10: + return kVK_F10 + case .f11: + return kVK_F11 + case .f12: + return kVK_F12 + case .f13: + return kVK_F13 + case .f14: + return kVK_F14 + case .f15: + return kVK_F15 + case .f16: + return kVK_F16 + case .f17: + return kVK_F17 + case .f18: + return kVK_F18 + case .f19: + return kVK_F19 + case .f20: + return kVK_F20 + case .help: + return kVK_Help + case .home: + return kVK_Home + case .pageUp: + return kVK_PageUp + case .forwardDelete: + return kVK_ForwardDelete + case .end: + return kVK_End + case .pageDown: + return kVK_PageDown + case .leftArrow: + return kVK_LeftArrow + case .rightArrow: + return kVK_RightArrow + case .downArrow: + return kVK_DownArrow + case .upArrow: + return kVK_UpArrow + } + } +} + +enum SetupError: Error { + case unknownCharacters(String) + case eventCreationError +} + +#endif diff --git a/Tests/RunestoneTests/TextView/TextViewMacTests.swift b/Tests/RunestoneTests/TextView/TextViewMacTests.swift new file mode 100644 index 000000000..65df0b66e --- /dev/null +++ b/Tests/RunestoneTests/TextView/TextViewMacTests.swift @@ -0,0 +1,44 @@ +#if os(macOS) +import AppKit +import Runestone +import XCTest + +class TextViewMacTests: XCTestCase { + func testMovingInDocument() throws { + let textView = createTextView(text: "Hello,\nWorld") + + // moveToEndOfParagraph: + textView.keyDown(with: try .create(characters: "e", modifiers: .control)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 6, length: 0)) + // moveLeft: + textView.keyDown(with: try .create(key: .leftArrow)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 5, length: 0)) + // moveRight: + textView.keyDown(with: try .create(key: .rightArrow)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 6, length: 0)) + // moveToBeginningOfParagraph: + textView.keyDown(with: try .create(characters: "a", modifiers: .control)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 0, length: 0)) + // moveDown: + textView.keyDown(with: try .create(characters: "n", modifiers: .control)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 7, length: 0)) + // moveUp: + textView.keyDown(with: try .create(characters: "p", modifiers: .control)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 0, length: 0)) + // moveDown: + textView.keyDown(with: try .create(key: .downArrow)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 7, length: 0)) + // moveUp: + textView.keyDown(with: try .create(key: .upArrow)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 0, length: 0)) + } + + private func createTextView(text: String, selectedRange: NSRange = NSRange(location: 0, length: 0)) -> TextView { + let textView = TextView() + textView.text = text + textView.frame = CGRect(x: 0, y: 0, width: 400, height: 400) + + return textView + } +} +#endif From 55e18b5338d8532d89535202accda2a546eab323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 10:01:25 +0100 Subject: [PATCH 151/232] Aligns styling of Mac navigation tests (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Infrastructure for testing key bindings. * Renames types related to movement test * Fixes SwiftLint warnings --------- Co-authored-by: Lukas Stührk --- .../Helpers/NSEvent+Helpers.swift | 115 ++++++++++++++++++ Tests/RunestoneTests/StringViewTests.swift | 9 +- Tests/RunestoneTests/TextViewTests_Mac.swift | 44 +++++++ 3 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 Tests/RunestoneTests/Helpers/NSEvent+Helpers.swift create mode 100644 Tests/RunestoneTests/TextViewTests_Mac.swift diff --git a/Tests/RunestoneTests/Helpers/NSEvent+Helpers.swift b/Tests/RunestoneTests/Helpers/NSEvent+Helpers.swift new file mode 100644 index 000000000..788edfa3c --- /dev/null +++ b/Tests/RunestoneTests/Helpers/NSEvent+Helpers.swift @@ -0,0 +1,115 @@ +#if os(macOS) +import AppKit +import Carbon + +private enum NSEventError: LocalizedError { + case unknownCharacters(String) + case failedCreatingEvent + + var errorDescription: String? { + switch self { + case .unknownCharacters(let string): + return "Unknown characters '\(string)'" + case .failedCreatingEvent: + return "Failed creating event" + } + } +} + +extension NSEvent { + /// Creates an event which can be used in tests. + /// + /// Simulates a key down event for the given ASCII character. + static func keyEvent(pressing characters: String, withModifiers modifiers: NSEvent.ModifierFlags) throws -> NSEvent { + guard let keyCode = keyMapping[characters] else { + throw NSEventError.unknownCharacters(characters) + } + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: CFTimeInterval(), + windowNumber: 0, + context: nil, + characters: "", + charactersIgnoringModifiers: characters, + isARepeat: false, + keyCode: keyCode + ) else { + throw NSEventError.failedCreatingEvent + } + return event + } + + /// Creates an event which can be used in tests. + /// + /// Simulates a key down event for the given device-independent key. + static func keyEvent(pressing key: NSEvent.Key, withModifiers modifiers: NSEvent.ModifierFlags = []) throws -> NSEvent { + let keyCode = CGKeyCode(UInt16(key.code)) + guard let cgEvent = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true), + let nsEvent = NSEvent(cgEvent: cgEvent) else { + throw NSEventError.failedCreatingEvent + } + guard let event = NSEvent.keyEvent( + with: .keyDown, + location: .zero, + modifierFlags: modifiers, + timestamp: CFTimeInterval(), + windowNumber: 0, + context: nil, + characters: nsEvent.characters ?? "", + charactersIgnoringModifiers: nsEvent.charactersIgnoringModifiers ?? "", + isARepeat: false, + keyCode: keyCode + ) else { + throw NSEventError.failedCreatingEvent + } + return event + } +} + +extension NSEvent { + enum Key { + case leftArrow + case upArrow + case rightArrow + case downArrow + + var code: Int { + switch self { + case .leftArrow: + return kVK_LeftArrow + case .upArrow: + return kVK_UpArrow + case .rightArrow: + return kVK_RightArrow + case .downArrow: + return kVK_DownArrow + } + } + } +} + +private extension NSEvent { + /// A mapping where the mapping's key is an ASCII character and the value is the key code for the character based on current keyboard. + /// This is used to translate keyboard-dependent characters into the correct keyboard. + private static var keyMapping: [String: UInt16] { + var mapping: [String: UInt16] = [:] + for keyCode in (0 ..< 128) { + guard let cgevent = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(keyCode), keyDown: true) else { + continue + } + guard let nsevent = NSEvent(cgEvent: cgevent) else { + continue + } + guard nsevent.type == .keyDown, nsevent.specialKey == nil, + let characters = nsevent.charactersIgnoringModifiers, + !characters.isEmpty else { + continue + } + mapping[characters] = UInt16(keyCode) + } + return mapping + } +} +#endif diff --git a/Tests/RunestoneTests/StringViewTests.swift b/Tests/RunestoneTests/StringViewTests.swift index 56b45e671..ceb6e4971 100644 --- a/Tests/RunestoneTests/StringViewTests.swift +++ b/Tests/RunestoneTests/StringViewTests.swift @@ -25,20 +25,23 @@ final class StringViewTests: XCTestCase { func testPassingValidIndexToCharacterAt() { let str = "Hello world" let stringView = StringView(string: str) - XCTAssertEqual(stringView.character(at: 4), "o") + let character = stringView.substring(in: NSRange(location: 4, length: 1)) + XCTAssertEqual(character, "o") } func testPassingInvalidIndexToCharacterAt() { let str = "Hello world" let stringView = StringView(string: str) - XCTAssertNil(stringView.character(at: 12)) + let character = stringView.substring(in: NSRange(location: 12, length: 1)) + XCTAssertNil(character) } func testGetCharacterFromEmojiString() { // Should return nil because the first character in a composed glyph isn't a valid Unicode.Scalar. let str = "🥳🥳" let stringView = StringView(string: str) - XCTAssertNil(stringView.character(at: 0)) + let character = stringView.substring(in: NSRange(location: 0, length: 1)) + XCTAssertNil(character) } func testGetBytesOfFirstCharacter() { diff --git a/Tests/RunestoneTests/TextViewTests_Mac.swift b/Tests/RunestoneTests/TextViewTests_Mac.swift new file mode 100644 index 000000000..9a22a3148 --- /dev/null +++ b/Tests/RunestoneTests/TextViewTests_Mac.swift @@ -0,0 +1,44 @@ +#if os(macOS) +import AppKit +import Runestone +import XCTest + +final class TextViewTestsMac: XCTestCase { + func testMovingInDocument() throws { + let textView = makeTextView(withText: "Hello,\nWorld") + // moveToEndOfParagraph: + textView.keyDown(with: try .keyEvent(pressing: "e", withModifiers: .control)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 6, length: 0)) + // moveLeft: + textView.keyDown(with: try .keyEvent(pressing: .leftArrow)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 5, length: 0)) + // moveRight: + textView.keyDown(with: try .keyEvent(pressing: .rightArrow)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 6, length: 0)) + // moveToBeginningOfParagraph: + textView.keyDown(with: try .keyEvent(pressing: "a", withModifiers: .control)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 0, length: 0)) + // moveDown: + textView.keyDown(with: try .keyEvent(pressing: "n", withModifiers: .control)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 7, length: 0)) + // moveUp: + textView.keyDown(with: try .keyEvent(pressing: "p", withModifiers: .control)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 0, length: 0)) + // moveDown: + textView.keyDown(with: try .keyEvent(pressing: .downArrow)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 7, length: 0)) + // moveUp: + textView.keyDown(with: try .keyEvent(pressing: .upArrow)) + XCTAssertEqual(textView.selectedRange(), NSRange(location: 0, length: 0)) + } +} + +private extension TextViewTestsMac { + private func makeTextView(withText text: String) -> TextView { + let textView = TextView() + textView.text = text + textView.frame = CGRect(x: 0, y: 0, width: 400, height: 400) + return textView + } +} +#endif From ba6c7ddff98c4961d595b91fdf4103a81fa7ffd8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 10:07:07 +0100 Subject: [PATCH 152/232] Fixes unit test --- Tests/RunestoneTests/StringViewTests.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Tests/RunestoneTests/StringViewTests.swift b/Tests/RunestoneTests/StringViewTests.swift index ceb6e4971..37169dfa3 100644 --- a/Tests/RunestoneTests/StringViewTests.swift +++ b/Tests/RunestoneTests/StringViewTests.swift @@ -37,11 +37,10 @@ final class StringViewTests: XCTestCase { } func testGetCharacterFromEmojiString() { - // Should return nil because the first character in a composed glyph isn't a valid Unicode.Scalar. let str = "🥳🥳" let stringView = StringView(string: str) - let character = stringView.substring(in: NSRange(location: 0, length: 1)) - XCTAssertNil(character) + let character = stringView.substring(in: NSRange(location: 0, length: 2)) + XCTAssertEqual(character, "🥳") } func testGetBytesOfFirstCharacter() { From 241a3c659828b69631f0a7e868605f04dd53542a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 10:07:12 +0100 Subject: [PATCH 153/232] Removes unused files (#276) --- .../Helpers/NSEvent+create.swift | 230 ------------------ .../TextView/TextViewMacTests.swift | 44 ---- 2 files changed, 274 deletions(-) delete mode 100644 Tests/RunestoneTests/Helpers/NSEvent+create.swift delete mode 100644 Tests/RunestoneTests/TextView/TextViewMacTests.swift diff --git a/Tests/RunestoneTests/Helpers/NSEvent+create.swift b/Tests/RunestoneTests/Helpers/NSEvent+create.swift deleted file mode 100644 index bb2cff370..000000000 --- a/Tests/RunestoneTests/Helpers/NSEvent+create.swift +++ /dev/null @@ -1,230 +0,0 @@ -#if os(macOS) -import AppKit -import Carbon - -extension NSEvent { - /// Creates an event which can be used in tests. - /// It simulates a key down event for the given ASCII character. - static func create(characters: String, modifiers: NSEvent.ModifierFlags) throws -> NSEvent { - guard let keyCode = keyMapping[characters] else { throw SetupError.unknownCharacters(characters) } - - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: modifiers, - timestamp: CFTimeInterval(), - windowNumber: 0, - context: nil, - characters: "", - charactersIgnoringModifiers: characters, - isARepeat: false, - keyCode: keyCode - ) else { throw SetupError.eventCreationError } - - return event - } - - /// Creates an event which can be used in tests. - /// It simulates a key down event for the given device-independent key. - static func create(key: KeyboardIndependentKeys, modifiers: NSEvent.ModifierFlags = []) throws -> NSEvent { - let keyCode = CGKeyCode(UInt16(key.keyCode)) - guard - let cgEvent = CGEvent(keyboardEventSource: nil, virtualKey: keyCode, keyDown: true), - let nsEvent = NSEvent(cgEvent: cgEvent) - else { throw SetupError.eventCreationError } - - guard let event = NSEvent.keyEvent( - with: .keyDown, - location: .zero, - modifierFlags: modifiers, - timestamp: CFTimeInterval(), - windowNumber: 0, - context: nil, - characters: nsEvent.characters ?? "", - charactersIgnoringModifiers: nsEvent.charactersIgnoringModifiers ?? "", - isARepeat: false, - keyCode: keyCode - ) else { throw SetupError.eventCreationError } - - return event - } -} - -/// A mapping where the mapping's key is an ASCII character and the value is the key code for the character based on current keyboard. -/// This is used to translate keyboard-dependent characters into the correct keyboard. -private let keyMapping: [String: UInt16] = { - var mapping: [String: UInt16] = [:] - for keyCode in (0..<128) { - guard let cgevent = CGEvent(keyboardEventSource: nil, virtualKey: CGKeyCode(keyCode), keyDown: true) else { continue } - guard let nsevent = NSEvent(cgEvent: cgevent) else { continue } - - guard nsevent.type == .keyDown, - nsevent.specialKey == nil, - let characters = nsevent.charactersIgnoringModifiers, - !characters.isEmpty - else { continue } - mapping[characters] = UInt16(keyCode) - } - - return mapping -}() - -/// All keys which are independent from the keyboard, so they have the same key code on all keyboards. -enum KeyboardIndependentKeys { - case `return` - case tab - case space - case delete - case escape - case command - case shift - case capsLock - case option - case control - case rightCommand - case rightShift - case rightOption - case rightControl - case function - case volumeUp - case volumeDown - case mute - case f1 - case f2 - case f3 - case f4 - case f5 - case f6 - case f7 - case f8 - case f9 - case f10 - case f11 - case f12 - case f13 - case f14 - case f15 - case f16 - case f17 - case f18 - case f19 - case f20 - case help - case home - case pageUp - case forwardDelete - case end - case pageDown - case leftArrow - case rightArrow - case downArrow - case upArrow - - var keyCode: Int { - switch self { - case .`return`: - return kVK_Return - case .tab: - return kVK_Tab - case .space: - return kVK_Space - case .delete: - return kVK_Delete - case .escape: - return kVK_Escape - case .command: - return kVK_Command - case .shift: - return kVK_Shift - case .capsLock: - return kVK_CapsLock - case .option: - return kVK_Option - case .control: - return kVK_Control - case .rightCommand: - return kVK_RightCommand - case .rightShift: - return kVK_RightShift - case .rightOption: - return kVK_RightOption - case .rightControl: - return kVK_RightControl - case .function: - return kVK_Function - case .volumeUp: - return kVK_VolumeUp - case .volumeDown: - return kVK_VolumeDown - case .mute: - return kVK_Mute - case .f1: - return kVK_F1 - case .f2: - return kVK_F2 - case .f3: - return kVK_F3 - case .f4: - return kVK_F4 - case .f5: - return kVK_F5 - case .f6: - return kVK_F6 - case .f7: - return kVK_F7 - case .f8: - return kVK_F8 - case .f9: - return kVK_F9 - case .f10: - return kVK_F10 - case .f11: - return kVK_F11 - case .f12: - return kVK_F12 - case .f13: - return kVK_F13 - case .f14: - return kVK_F14 - case .f15: - return kVK_F15 - case .f16: - return kVK_F16 - case .f17: - return kVK_F17 - case .f18: - return kVK_F18 - case .f19: - return kVK_F19 - case .f20: - return kVK_F20 - case .help: - return kVK_Help - case .home: - return kVK_Home - case .pageUp: - return kVK_PageUp - case .forwardDelete: - return kVK_ForwardDelete - case .end: - return kVK_End - case .pageDown: - return kVK_PageDown - case .leftArrow: - return kVK_LeftArrow - case .rightArrow: - return kVK_RightArrow - case .downArrow: - return kVK_DownArrow - case .upArrow: - return kVK_UpArrow - } - } -} - -enum SetupError: Error { - case unknownCharacters(String) - case eventCreationError -} - -#endif diff --git a/Tests/RunestoneTests/TextView/TextViewMacTests.swift b/Tests/RunestoneTests/TextView/TextViewMacTests.swift deleted file mode 100644 index 65df0b66e..000000000 --- a/Tests/RunestoneTests/TextView/TextViewMacTests.swift +++ /dev/null @@ -1,44 +0,0 @@ -#if os(macOS) -import AppKit -import Runestone -import XCTest - -class TextViewMacTests: XCTestCase { - func testMovingInDocument() throws { - let textView = createTextView(text: "Hello,\nWorld") - - // moveToEndOfParagraph: - textView.keyDown(with: try .create(characters: "e", modifiers: .control)) - XCTAssertEqual(textView.selectedRange(), NSRange(location: 6, length: 0)) - // moveLeft: - textView.keyDown(with: try .create(key: .leftArrow)) - XCTAssertEqual(textView.selectedRange(), NSRange(location: 5, length: 0)) - // moveRight: - textView.keyDown(with: try .create(key: .rightArrow)) - XCTAssertEqual(textView.selectedRange(), NSRange(location: 6, length: 0)) - // moveToBeginningOfParagraph: - textView.keyDown(with: try .create(characters: "a", modifiers: .control)) - XCTAssertEqual(textView.selectedRange(), NSRange(location: 0, length: 0)) - // moveDown: - textView.keyDown(with: try .create(characters: "n", modifiers: .control)) - XCTAssertEqual(textView.selectedRange(), NSRange(location: 7, length: 0)) - // moveUp: - textView.keyDown(with: try .create(characters: "p", modifiers: .control)) - XCTAssertEqual(textView.selectedRange(), NSRange(location: 0, length: 0)) - // moveDown: - textView.keyDown(with: try .create(key: .downArrow)) - XCTAssertEqual(textView.selectedRange(), NSRange(location: 7, length: 0)) - // moveUp: - textView.keyDown(with: try .create(key: .upArrow)) - XCTAssertEqual(textView.selectedRange(), NSRange(location: 0, length: 0)) - } - - private func createTextView(text: String, selectedRange: NSRange = NSRange(location: 0, length: 0)) -> TextView { - let textView = TextView() - textView.text = text - textView.frame = CGRect(x: 0, y: 0, width: 400, height: 400) - - return textView - } -} -#endif From 3db2d50aac129791f7fcc5af849a2561412dfb9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 10:10:42 +0100 Subject: [PATCH 154/232] Right click ignores event count --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 0c7e92630..c20693ce1 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -547,7 +547,7 @@ open class TextView: NSView, NSMenuItemValidation { } override public func rightMouseDown(with event: NSEvent) { - if event.clickCount == 1, let location = locationClosestToPoint(in: event) { + if let location = locationClosestToPoint(in: event) { if let selectedRange = textViewController.selectedRange, !selectedRange.contains(location) || textViewController.selectedRange == nil { textViewController.selectWord(at: location) } From e98ce90cf001f4f3ac91ee64979e60b4bb6cb80f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 10:27:49 +0100 Subject: [PATCH 155/232] Fixes app name --- Example/MacExample/Base.lproj/Main.storyboard | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Example/MacExample/Base.lproj/Main.storyboard b/Example/MacExample/Base.lproj/Main.storyboard index 04f4875d2..770dc3131 100644 --- a/Example/MacExample/Base.lproj/Main.storyboard +++ b/Example/MacExample/Base.lproj/Main.storyboard @@ -12,9 +12,9 @@ - + - + From cfa85b8e66bc3afe696fc76bbd389496462c4e6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 10:27:49 +0100 Subject: [PATCH 156/232] Fixes app name --- Example/MacExample/Base.lproj/Main.storyboard | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Example/MacExample/Base.lproj/Main.storyboard b/Example/MacExample/Base.lproj/Main.storyboard index 04f4875d2..6feb6c400 100644 --- a/Example/MacExample/Base.lproj/Main.storyboard +++ b/Example/MacExample/Base.lproj/Main.storyboard @@ -12,11 +12,11 @@ - + - + - + @@ -30,7 +30,7 @@ - + @@ -48,7 +48,7 @@ - + @@ -661,7 +661,7 @@ - + From 59049dacb4cb2de5b291db7d098e705503920329 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 12:00:30 +0100 Subject: [PATCH 157/232] Improves documentation --- .../Documentation.docc/Extensions/TextView.md | 116 +++++-- .../TextView/Core/LayoutManager.swift | 2 +- .../Core/Mac/TextView_Mac+Commands.swift | 103 ++++++ .../Mac/TextView_Mac+KeyboardEvents.swift | 15 + .../Mac/TextView_Mac+KeyboardNavigation.swift | 163 ++++++++++ .../Core/Mac/TextView_Mac+MouseEvents.swift | 55 ++++ .../Mac/TextView_Mac+NSTextInputClient.swift | 44 ++- .../TextView/Core/Mac/TextView_Mac.swift | 306 ++---------------- .../Core/iOS/TextView_iOS+UITextInput.swift | 100 +++++- .../TextView/Core/iOS/TextView_iOS.swift | 17 +- 10 files changed, 595 insertions(+), 326 deletions(-) create mode 100644 Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift create mode 100644 Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardEvents.swift create mode 100644 Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardNavigation.swift create mode 100644 Sources/Runestone/TextView/Core/Mac/TextView_Mac+MouseEvents.swift diff --git a/Sources/Runestone/Documentation.docc/Extensions/TextView.md b/Sources/Runestone/Documentation.docc/Extensions/TextView.md index ddd635211..f59810731 100644 --- a/Sources/Runestone/Documentation.docc/Extensions/TextView.md +++ b/Sources/Runestone/Documentation.docc/Extensions/TextView.md @@ -4,9 +4,22 @@ ### Initialing the Text View +- ``init()`` - ``init(frame:)`` - ``init(coder:)`` +### Lifecycle + +- ``isFlipped`` +- ``didMoveToWindow()`` +- ``layoutSubviews()`` +- ``safeAreaInsetsDidChange()`` +- ``traitCollectionDidChange(_:)`` +- ``viewDidMoveToWindow()`` +- ``resizeSubviews(withOldSize:)`` +- ``layoutSubtreeIfNeeded()`` +- ``resetCursorRects()`` + ### Responding to Text View Changes - ``editorDelegate`` @@ -15,7 +28,6 @@ ### Configuring the Appearance - ``theme`` -- ``backgroundColor`` - ``kern`` - ``lineHeightMultiplier`` - ``insertionPointColor`` @@ -64,6 +76,7 @@ - ``gutterLeadingPadding`` - ``gutterTrailingPadding`` - ``gutterWidth`` +- ``gutterMinimumCharacterCount`` ### Character Pairs @@ -111,6 +124,7 @@ - ``selectNextHighlightedRange()`` - ``selectPreviousHighlightedRange()`` - ``selectHighlightedRange(at:)`` +- ``showMenuAfterNavigatingToHighlightedRange`` ### Supporting Find and Replace @@ -133,13 +147,17 @@ - ``smartInsertDeleteType`` - ``smartQuotesType`` - ``text(in:)-3lp4v`` -- ``text(in:)-3wzco`` - ``insertText(_:)`` +- ``insertText(_:replacementRange:)`` +- ``insertNewline(_:)`` +- ``insertTab(_:)`` - ``replaceText(in:)`` - ``replace(_:withText:)-7gret`` -- ``replace(_:withText:)-7ugo8`` - ``deleteBackward()`` +- ``deleteBackward(_:)`` - ``undoManager`` +- ``undo(_:)`` +- ``redo(_:)`` ### Managing the Keyboard @@ -147,12 +165,11 @@ - ``keyboardType`` - ``returnKeyType`` - ``inputAccessoryView`` -- ``inputAssistantItem`` -- ``reloadInputViews()`` ### Selecting Text - ``selectedRange`` +- ``selectedRange()`` - ``selectedTextRange`` - ``selectionBarColor`` - ``selectionHighlightColor`` @@ -162,34 +179,75 @@ - ``contentOffset`` - ``isAutomaticScrollEnabled`` -### Laying Out Subviews - -- ``layoutSubviews()`` -- ``safeAreaInsetsDidChange()`` +### Keyboard Events + +- ``keyDown(with:)`` + +### Keyboard Navigation + +- ``moveBackward(_:)`` +- ``moveBackwardAndModifySelection(_:)`` +- ``moveDown(_:)`` +- ``moveDownAndModifySelection(_:)`` +- ``moveForward(_:)`` +- ``moveForwardAndModifySelection(_:)`` +- ``moveLeft(_:)`` +- ``moveLeftAndModifySelection(_:)`` +- ``moveRight(_:)`` +- ``moveRightAndModifySelection(_:)`` +- ``moveToBeginningOfDocument(_:)`` +- ``moveToBeginningOfDocumentAndModifySelection(_:)`` +- ``moveToBeginningOfLineAndModifySelection(_:)`` +- ``moveToBeginningOfLine(_:)`` +- ``moveToBeginningOfLineAndModifySelection(_:)`` +- ``moveToBeginningOfParagraph(_:)`` +- ``moveToBeginningOfParagraphAndModifySelection(_:)`` +- ``moveToEndOfDocument(_:)`` +- ``moveToEndOfDocumentAndModifySelection(_:)`` +- ``moveToEndOfLine(_:)`` +- ``moveToEndOfLineAndModifySelection(_:)`` +- ``moveToEndOfParagraph(_:)`` +- ``moveToEndOfParagraphAndModifySelection(_:)`` +- ``moveUp(_:)`` +- ``moveUpAndModifySelection(_:)`` +- ``moveWordBackward(_:)`` +- ``moveWordBackwardAndModifySelection(_:)`` +- ``moveWordForward(_:)`` +- ``moveWordForwardAndModifySelection(_:)`` +- ``moveWordLeft(_:)`` +- ``moveWordLeftAndModifySelection(_:)`` +- ``moveWordRight(_:)`` +- ``moveWordRightAndModifySelection(_:)`` + +### Mouse Events + +- ``mouseDown(with:)`` +- ``mouseDragged(with:)`` +- ``mouseUp(with:)`` +- ``rightMouseDown(with:)`` + +### Interactions + +- ``hitTest(_:with:)`` +- ``pressesEnded(_:with:)`` + +### Commands + +- ``cut(_:)`` +- ``copy(_:)`` +- ``paste(_:)`` +- ``selectAll(_:)`` +- ``replace(_:withText:)-7cbas`` +- ``canPerformAction(_:withSender:)`` ### Responder Chain - ``canBecomeFirstResponder`` +- ``acceptsFirstResponder`` - ``becomeFirstResponder()`` - ``resignFirstResponder()`` +- ``validateMenuItem(_:)`` + +### Text Input Conformance -### UITextInput Conformace - -- ``hasText`` -- ``beginningOfDocument`` -- ``endOfDocument`` -- ``markedTextRange`` -- ``tokenizer`` -- ``textRange(from:to:)`` -- ``position(from:offset:)`` -- ``position(from:in:offset:)`` -- ``position(within:farthestIn:)`` -- ``closestPosition(to:)`` -- ``closestPosition(to:within:)`` -- ``compare(_:to:)`` -- ``offset(from:to:)`` -- ``characterRange(at:)`` -- ``characterRange(byExtending:in:)`` -- ``caretRect(for:)`` -- ``firstRect(for:)`` -- ``selectionRects(for:)`` +- ``inputDelegate`` diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index 12727e0b5..5dc79fa16 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -244,7 +244,7 @@ extension LayoutManager { return CGRect(x: xPosition, y: yPosition, width: width, height: lineContentsRect.height) } - func closestIndex(to point: CGPoint) -> Int? { + func closestIndex(to point: CGPoint) -> Int { let adjustedXPosition = point.x - textContainerInset.left let adjustedYPosition = point.y - textContainerInset.top let adjustedPoint = CGPoint(x: adjustedXPosition, y: adjustedYPosition) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift new file mode 100644 index 000000000..ae48b5f3c --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift @@ -0,0 +1,103 @@ +#if os(macOS) +import AppKit + +public extension TextView { + /// Deletes a character from the displayed text. + override func deleteBackward(_ sender: Any?) { + guard var selectedRange = textViewController.markedRange ?? textViewController.selectedRange?.nonNegativeLength else { + return + } + if selectedRange.length == 0 { + selectedRange.location -= 1 + selectedRange.length = 1 + } + let deleteRange = textViewController.rangeForDeletingText(in: selectedRange) + // If we're deleting everything in the marked range then we clear the marked range. UITextInput doesn't do that for us. + // Can be tested by entering a backtick (`) in an empty document and deleting it. + if deleteRange == textViewController.markedRange { + textViewController.markedRange = nil + } + guard textViewController.shouldChangeText(in: deleteRange, replacementText: "") else { + return + } + let isDeletingMultipleCharacters = selectedRange.length > 1 + if isDeletingMultipleCharacters { + undoManager?.endUndoGrouping() + undoManager?.beginUndoGrouping() + } + textViewController.replaceText(in: deleteRange, with: "", selectedRangeAfterUndo: selectedRange) + if isDeletingMultipleCharacters { + undoManager?.endUndoGrouping() + } + } + + /// Inserts a newline character. + override func insertNewline(_ sender: Any?) { + if textViewController.shouldChangeText(in: textViewController.rangeForInsertingText, replacementText: lineEndings.symbol) { + textViewController.indentController.insertLineBreak(in: textViewController.rangeForInsertingText, using: lineEndings.symbol) + } + } + + /// Inserts a tab character. + override func insertTab(_ sender: Any?) { + let indentString = indentStrategy.string(indentLevel: 1) + if textViewController.shouldChangeText(in: textViewController.rangeForInsertingText, replacementText: indentString) { + textViewController.replaceText(in: textViewController.rangeForInsertingText, with: indentString) + } + } + + /// Copy the selected text. + /// + /// - Parameter sender: The object calling this method. + @objc func copy(_ sender: Any?) { + let selectedRange = selectedRange() + if selectedRange.length > 0, let text = textViewController.text(in: selectedRange) { + NSPasteboard.general.declareTypes([.string], owner: nil) + NSPasteboard.general.setString(text, forType: .string) + } + } + + /// Paste text from the pasteboard. + /// + /// - Parameter sender: The object calling this method. + @objc func paste(_ sender: Any?) { + let selectedRange = selectedRange() + if let string = NSPasteboard.general.string(forType: .string) { + let preparedText = textViewController.prepareTextForInsertion(string) + textViewController.replaceText(in: selectedRange, with: preparedText) + } + } + + /// Cut text to the pasteboard. + /// + /// - Parameter sender: The object calling this method. + @objc func cut(_ sender: Any?) { + let selectedRange = selectedRange() + if selectedRange.length > 0, let text = textViewController.text(in: selectedRange) { + NSPasteboard.general.setString(text, forType: .string) + textViewController.replaceText(in: selectedRange, with: "") + } + } + + /// Select all text in the text view. + /// + /// - Parameter sender: The object calling this method. + override func selectAll(_ sender: Any?) { + textViewController.selectedRange = NSRange(location: 0, length: textViewController.stringView.string.length) + } + + /// Performs the undo operations in the last undo group. + @objc func undo(_ sender: Any?) { + if let undoManager = undoManager, undoManager.canUndo { + undoManager.undo() + } + } + + /// Performs the operations in the last group on the redo stack. + @objc func redo(_ sender: Any?) { + if let undoManager = undoManager, undoManager.canRedo { + undoManager.redo() + } + } +} +#endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardEvents.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardEvents.swift new file mode 100644 index 000000000..2c95d41e1 --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardEvents.swift @@ -0,0 +1,15 @@ +#if os(macOS) +import AppKit + +public extension TextView { + /// Informs the receiver that the user has pressed a key. + /// - Parameter event: An object encapsulating information about the key-down event. + override func keyDown(with event: NSEvent) { + NSCursor.setHiddenUntilMouseMoves(true) + let didInputContextHandleEvent = inputContext?.handleEvent(event) ?? false + if !didInputContextHandleEvent { + super.keyDown(with: event) + } + } +} +#endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardNavigation.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardNavigation.swift new file mode 100644 index 000000000..8323bc1aa --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardNavigation.swift @@ -0,0 +1,163 @@ +#if os(macOS) +public extension TextView { + /// Moves the insertion pointer backward in the current content. + override func moveBackward(_ sender: Any?) { + textViewController.moveLeft() + } + + /// Extends the selection to include the content before the current selection. + override func moveBackwardAndModifySelection(_ sender: Any?) { + textViewController.moveLeftAndModifySelection() + } + + /// Moves the insertion pointer down in the current content. + override func moveDown(_ sender: Any?) { + textViewController.moveDown() + } + + /// Extends the selection to include the content below the current selection. + override func moveDownAndModifySelection(_ sender: Any?) { + textViewController.moveDownAndModifySelection() + } + + /// Moves the insertion pointer forward in the current content. + override func moveForward(_ sender: Any?) { + textViewController.moveRight() + } + + /// Extends the selection to include the content below the current selection. + override func moveForwardAndModifySelection(_ sender: Any?) { + textViewController.moveRightAndModifySelection() + } + + /// Moves the insertion pointer left in the current content. + override func moveLeft(_ sender: Any?) { + textViewController.moveLeft() + } + + /// Extends the selection to include the content to the left of the current selection. + override func moveLeftAndModifySelection(_ sender: Any?) { + textViewController.moveLeftAndModifySelection() + } + + /// Moves the insertion pointer right in the current content. + override func moveRight(_ sender: Any?) { + textViewController.moveRight() + } + + /// Extends the selection to include the content to the right of the current selection. + override func moveRightAndModifySelection(_ sender: Any?) { + textViewController.moveRightAndModifySelection() + } + + /// Move the insertion pointer to the beginning of the document. + override func moveToBeginningOfDocument(_ sender: Any?) { + textViewController.moveToBeginningOfDocument() + } + + /// Move the selection to include the beginning of the document. + override func moveToBeginningOfDocumentAndModifySelection(_ sender: Any?) { + textViewController.moveToBeginningOfDocumentAndModifySelection() + } + + /// Move the insertion pointer to the beginning of the line. + override func moveToBeginningOfLine(_ sender: Any?) { + textViewController.moveToBeginningOfLine() + } + + /// Move the selection to include the beginning of the line. + override func moveToBeginningOfLineAndModifySelection(_ sender: Any?) { + textViewController.moveToBeginningOfLineAndModifySelection() + } + + /// Move the insertion pointer to the beginning of the paragraph. + override func moveToBeginningOfParagraph(_ sender: Any?) { + textViewController.moveToBeginningOfParagraph() + } + + /// Move the selection to include the beginning of the paragraph. + override func moveToBeginningOfParagraphAndModifySelection(_ sender: Any?) { + textViewController.moveToBeginningOfParagraphAndModifySelection() + } + + /// Move the insertion pointer to the end of the document. + override func moveToEndOfDocument(_ sender: Any?) { + textViewController.moveToEndOfDocument() + } + + /// Move the selection to include the end of the document. + override func moveToEndOfDocumentAndModifySelection(_ sender: Any?) { + textViewController.moveToEndOfDocumentAndModifySelection() + } + + /// Move the insertion pointer to the end of the line. + override func moveToEndOfLine(_ sender: Any?) { + textViewController.moveToEndOfLine() + } + + /// Move the selection to include the end of the line. + override func moveToEndOfLineAndModifySelection(_ sender: Any?) { + textViewController.moveToEndOfLineAndModifySelection() + } + + /// Move the insertion pointer to the end of the paragraph. + override func moveToEndOfParagraph(_ sender: Any?) { + textViewController.moveToEndOfParagraph() + } + + /// Move the selection to include the end of the paragraph. + override func moveToEndOfParagraphAndModifySelection(_ sender: Any?) { + textViewController.moveToEndOfParagraphAndModifySelection() + } + + /// Moves the insertion pointer up in the current content. + override func moveUp(_ sender: Any?) { + textViewController.moveUp() + } + + /// Extends the selection to include the content above the current selection. + override func moveUpAndModifySelection(_ sender: Any?) { + textViewController.moveUpAndModifySelection() + } + + /// Move the insertion point one word backward. + override func moveWordBackward(_ sender: Any?) { + textViewController.moveWordLeft() + } + + /// Extends the selection to include the word in the backward direction. + override func moveWordBackwardAndModifySelection(_ sender: Any?) { + textViewController.moveWordLeftAndModifySelection() + } + + /// Move the insertion point one word forward. + override func moveWordForward(_ sender: Any?) { + textViewController.moveWordRight() + } + + /// Extends the selection to include the word in the forward direction. + override func moveWordForwardAndModifySelection(_ sender: Any?) { + textViewController.moveWordRightAndModifySelection() + } + + /// Move the insertion point one word to the left. + override func moveWordLeft(_ sender: Any?) { + textViewController.moveWordLeft() + } + + /// Extends the selection to include the word to the left of the insertion pointer. + override func moveWordLeftAndModifySelection(_ sender: Any?) { + textViewController.moveWordLeftAndModifySelection() + } + + /// Move the insertion point one word to the right. + override func moveWordRight(_ sender: Any?) { + textViewController.moveWordRight() + } + + /// Extends the selection to include the word to the right of the insertion pointer. + override func moveWordRightAndModifySelection(_ sender: Any?) { + textViewController.moveWordRightAndModifySelection() + } +} +#endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+MouseEvents.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+MouseEvents.swift new file mode 100644 index 000000000..346cf8012 --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+MouseEvents.swift @@ -0,0 +1,55 @@ +#if os(macOS) +import AppKit + +public extension TextView { + /// Informs the receiver that the user has pressed the left mouse button. + /// - Parameter event: An object encapsulating information about the mouse-down event. + override func mouseDown(with event: NSEvent) { + super.mouseDown(with: event) + let location = locationClosestToPoint(in: event) + if event.clickCount == 1 { + textViewController.move(to: location) + textViewController.startDraggingSelection(from: location) + } else if event.clickCount == 2 { + textViewController.selectWord(at: location) + } else if event.clickCount == 3 { + textViewController.selectLine(at: location) + } + } + + /// Informs the receiver that the user has moved the mouse with the left button pressed. + /// - Parameter event: An object encapsulating information about the mouse-dragged event. + override func mouseDragged(with event: NSEvent) { + super.mouseDragged(with: event) + let location = locationClosestToPoint(in: event) + textViewController.extendDraggedSelection(to: location) + } + + /// Informs the receiver that the user has released the left mouse button. + /// - Parameter event: An object encapsulating information about the mouse-up event. + override func mouseUp(with event: NSEvent) { + super.mouseUp(with: event) + if event.clickCount == 1 { + let location = locationClosestToPoint(in: event) + textViewController.extendDraggedSelection(to: location) + } + } + + /// Informs the receiver that the user has pressed the right mouse button. + /// - Parameter event: An object encapsulating information about the mouse-down event. + override func rightMouseDown(with event: NSEvent) { + let location = locationClosestToPoint(in: event) + if let selectedRange = textViewController.selectedRange, !selectedRange.contains(location) || textViewController.selectedRange == nil { + textViewController.selectWord(at: location) + } + super.rightMouseDown(with: event) + } +} + +private extension TextView { + private func locationClosestToPoint(in event: NSEvent) -> Int { + let point = scrollContentView.convert(event.locationInWindow, from: nil) + return characterIndex(for: point) + } +} +#endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift index 913d06ab0..7da05f623 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift @@ -10,6 +10,15 @@ extension TextView: NSTextInputClient { super.doCommand(by: selector) } + /// The current selection range of the text view. + public func selectedRange() -> NSRange { + textViewController.selectedRange?.nonNegativeLength ?? NSRange(location: 0, length: 0) + } + + /// Inserts the given string into the receiver, replacing the specified content. + /// - Parameters: + /// - string: The text to insert. + /// - replacementRange: The range of content to replace in the receiver's text storage. public func insertText(_ string: Any, replacementRange: NSRange) { guard let string = string as? String else { return @@ -20,36 +29,59 @@ extension TextView: NSTextInputClient { } } + /// Inserts the provided text and marks it to indicate that it is part of an active input session. + /// - Parameters: + /// - markedText: The text to be marked. + /// - selectedRange: A range within `markedText` that indicates the current selection. This range is always relative to `markedText`. public func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {} - public func unmarkText() {} - - public func selectedRange() -> NSRange { - textViewController.selectedRange?.nonNegativeLength ?? NSRange(location: 0, length: 0) + /// Unmarks the marked text. + public func unmarkText() { + textViewController.markedRange = nil } + /// Returns the range of the marked text. + /// - Returns: The range of marked text or {NSNotFound, 0} if there is no marked range. public func markedRange() -> NSRange { - textViewController.markedRange ?? NSRange(location: 0, length: 0) + textViewController.markedRange ?? NSRange(location: NSNotFound, length: 0) } + /// Returns a Boolean value indicating whether the receiver has marked text. + /// - Returns: `true` if the receiver has marked text; otherwise `false. public func hasMarkedText() -> Bool { (textViewController.markedRange?.length ?? 0) > 0 } + /// Returns an attributed string derived from the given range in the receiver's text storage. + /// - Parameters: + /// - range: The range in the text storage from which to create the returned string. + /// - actualRange: The actual range of the returned string if it was adjusted, for example, to a grapheme cluster boundary or for performance or other reasons. `NULL` if range was not adjusted. + /// - Returns: The string created from the given range. May return `nil`. public func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { nil } + /// Returns an array of attribute names recognized by the receiver. + /// - Returns: An array of `NSString` objects representing names for the supported attributes. public func validAttributesForMarkedText() -> [NSAttributedString.Key] { [] } + /// Returns the first logical boundary rectangle for characters in the given range. + /// - Parameters: + /// - range: The character range whose boundary rectangle is returned. + /// - actualRange: If non-NULL, contains the character range corresponding to the returned area if it was adjusted, for example, to a grapheme cluster boundary or characters in the first line fragment. + /// - Returns: The boundary rectangle for the given range of characters, in screen coordinates. The rectangle's `size` value can be negative if the text flows to the left. public func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { .zero } + /// Returns the index of the character whose bounding rectangle includes the given point. + /// - Parameter point: The point to test, in screen coordinates. + /// - Returns: The character index, measured from the start of the receiver's text storage, of the character containing the given point. public func characterIndex(for point: NSPoint) -> Int { - 0 + let adjustedPoint = CGPoint(x: point.x - gutterWidth - textContainerInset.left, y: point.y) + return textViewController.layoutManager.closestIndex(to: adjustedPoint) } } #endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index c20693ce1..96eb724d3 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -4,11 +4,22 @@ import AppKit import UniformTypeIdentifiers // swiftlint:disable:next type_body_length +/// A type similiar to NSTextView with features commonly found in code editors. +/// +/// `TextView` is a performant implementation of a text view with features such as showing line numbers, searching for text and replacing results, syntax highlighting, showing invisible characters and more. +/// +/// The type does not subclass `NSTextView` but its interface is kept close to `NSTextView`. +/// +/// When initially configuring the `TextView` with a theme, a language and the text to be shown, it is recommended to use the ``setState(_:addUndoAction:)`` function. +/// The function takes an instance of ``TextViewState`` as input which can be created on a background queue to avoid blocking the main queue while doing the initial parse of a text. open class TextView: NSView, NSMenuItemValidation { + /// Delegate to receive callbacks for events triggered by the editor. public weak var editorDelegate: TextViewDelegate? + /// Returns a Boolean value indicating whether this object can become the first responder. override public var acceptsFirstResponder: Bool { true } + /// A Boolean value indicating whether the view uses a flipped coordinate system. override public var isFlipped: Bool { true } @@ -365,7 +376,7 @@ open class TextView: NSView, NSMenuItemValidation { /// /// The value only affects new line breaks inserted in the text view and changing this value does not change the line endings of the text in the text view. Defaults to Unix (LF). /// - /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)`` and ``TextView/replaceText(in:)``. + /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)``. public var lineEndings: LineEnding { get { textViewController.lineEndings @@ -392,14 +403,15 @@ open class TextView: NSView, NSMenuItemValidation { } } } + /// The object that the document uses to support undo/redo operations. override open var undoManager: UndoManager? { textViewController.timedUndoManager } private(set) lazy var textViewController = TextViewController(textView: self, scrollView: scrollView) + let scrollContentView = FlippedView() private let scrollView = NSScrollView() - private let scrollContentView = FlippedView() private let caretView = CaretView() private let selectionViewReuseQueue = ViewReuseQueue() private var isWindowKey = false { @@ -434,6 +446,7 @@ open class TextView: NSView, NSMenuItemValidation { } } + /// Create a new text view. public init() { super.init(frame: .zero) textViewController.delegate = self @@ -453,6 +466,8 @@ open class TextView: NSView, NSMenuItemValidation { setupMenu() } + /// The initializer has not been implemented. + /// - Parameter coder: Not used. public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -461,6 +476,7 @@ open class TextView: NSView, NSMenuItemValidation { NotificationCenter.default.removeObserver(self) } + /// Notifies the receiver that it's about to become first responder in its NSWindow. @discardableResult override open func becomeFirstResponder() -> Bool { guard !isEditing && shouldBeginEditing else { @@ -477,6 +493,8 @@ open class TextView: NSView, NSMenuItemValidation { return didBecomeFirstResponder } + + /// Notifies the receiver that it's been asked to relinquish its status as first responder in its window. @discardableResult override open func resignFirstResponder() -> Bool { guard isEditing && shouldEndEditing else { @@ -491,6 +509,8 @@ open class TextView: NSView, NSMenuItemValidation { return didResignFirstResponder } + /// Informs the view's subviews that the view's bounds rectangle size has changed. + /// - Parameter oldSize: The previous size of the view's bounds rectangle. override public func resizeSubviews(withOldSize oldSize: NSSize) { super.resizeSubviews(withOldSize: oldSize) scrollView.frame = bounds @@ -502,59 +522,19 @@ open class TextView: NSView, NSMenuItemValidation { updateSelectedRectangles() } + /// Updates the layout of the receiving view and its subviews based on the current views and constraints. override public func layoutSubtreeIfNeeded() { super.layoutSubtreeIfNeeded() textViewController.layoutIfNeeded() } + /// Informs the view that it has been added to a new view hierarchy. override public func viewDidMoveToWindow() { super.viewDidMoveToWindow() textViewController.performFullLayoutIfNeeded() } - override public func keyDown(with event: NSEvent) { - NSCursor.setHiddenUntilMouseMoves(true) - let didInputContextHandleEvent = inputContext?.handleEvent(event) ?? false - if !didInputContextHandleEvent { - super.keyDown(with: event) - } - } - - override public func mouseDown(with event: NSEvent) { - super.mouseDown(with: event) - if event.clickCount == 1, let location = locationClosestToPoint(in: event) { - textViewController.move(to: location) - textViewController.startDraggingSelection(from: location) - } else if event.clickCount == 2, let location = locationClosestToPoint(in: event) { - textViewController.selectWord(at: location) - } else if event.clickCount == 3, let location = locationClosestToPoint(in: event) { - textViewController.selectLine(at: location) - } - } - - override public func mouseDragged(with event: NSEvent) { - super.mouseDragged(with: event) - if let location = locationClosestToPoint(in: event) { - textViewController.extendDraggedSelection(to: location) - } - } - - override public func mouseUp(with event: NSEvent) { - super.mouseUp(with: event) - if event.clickCount == 1, let location = locationClosestToPoint(in: event) { - textViewController.extendDraggedSelection(to: location) - } - } - - override public func rightMouseDown(with event: NSEvent) { - if let location = locationClosestToPoint(in: event) { - if let selectedRange = textViewController.selectedRange, !selectedRange.contains(location) || textViewController.selectedRange == nil { - textViewController.selectWord(at: location) - } - } - super.rightMouseDown(with: event) - } - + /// Overridden by subclasses to define their default cursor rectangles. override public func resetCursorRects() { super.resetCursorRects() addCursorRect(bounds, cursor: .iBeam) @@ -574,6 +554,9 @@ open class TextView: NSView, NSMenuItemValidation { textViewController.setState(state, addUndoAction: addUndoAction) } + /// Implemented to override the default action of enabling or disabling a specific menu item. + /// - Parameter menuItem: An NSMenuItem object that represents the menu item. + /// - Returns: `true` to enable menuItem, `false` to disable it. public func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { if menuItem.action == #selector(copy(_:)) || menuItem.action == #selector(cut(_:)) { return selectedRange().length > 0 @@ -591,230 +574,6 @@ open class TextView: NSView, NSMenuItemValidation { } } -// MARK: - Commands -public extension TextView { - override func deleteBackward(_ sender: Any?) { - guard var selectedRange = textViewController.markedRange ?? textViewController.selectedRange?.nonNegativeLength else { - return - } - if selectedRange.length == 0 { - selectedRange.location -= 1 - selectedRange.length = 1 - } - let deleteRange = textViewController.rangeForDeletingText(in: selectedRange) - // If we're deleting everything in the marked range then we clear the marked range. UITextInput doesn't do that for us. - // Can be tested by entering a backtick (`) in an empty document and deleting it. - if deleteRange == textViewController.markedRange { - textViewController.markedRange = nil - } - guard textViewController.shouldChangeText(in: deleteRange, replacementText: "") else { - return - } - let isDeletingMultipleCharacters = selectedRange.length > 1 - if isDeletingMultipleCharacters { - undoManager?.endUndoGrouping() - undoManager?.beginUndoGrouping() - } - textViewController.replaceText(in: deleteRange, with: "", selectedRangeAfterUndo: selectedRange) - if isDeletingMultipleCharacters { - undoManager?.endUndoGrouping() - } - } - - override func insertNewline(_ sender: Any?) { - if textViewController.shouldChangeText(in: textViewController.rangeForInsertingText, replacementText: lineEndings.symbol) { - textViewController.indentController.insertLineBreak(in: textViewController.rangeForInsertingText, using: lineEndings.symbol) - } - } - - override func insertTab(_ sender: Any?) { - let indentString = indentStrategy.string(indentLevel: 1) - if textViewController.shouldChangeText(in: textViewController.rangeForInsertingText, replacementText: indentString) { - textViewController.replaceText(in: textViewController.rangeForInsertingText, with: indentString) - } - } - - override func moveLeft(_ sender: Any?) { - textViewController.moveLeft() - } - - override func moveRight(_ sender: Any?) { - textViewController.moveRight() - } - - override func moveForward(_ sender: Any?) { - textViewController.moveRight() - } - - override func moveBackward(_ sender: Any?) { - textViewController.moveLeft() - } - - override func moveUp(_ sender: Any?) { - textViewController.moveUp() - } - - override func moveDown(_ sender: Any?) { - textViewController.moveDown() - } - - override func moveWordLeft(_ sender: Any?) { - textViewController.moveWordLeft() - } - - override func moveWordRight(_ sender: Any?) { - textViewController.moveWordRight() - } - - override func moveWordForward(_ sender: Any?) { - textViewController.moveWordRight() - } - - override func moveWordBackward(_ sender: Any?) { - textViewController.moveWordLeft() - } - - override func moveToBeginningOfLine(_ sender: Any?) { - textViewController.moveToBeginningOfLine() - } - - override func moveToEndOfLine(_ sender: Any?) { - textViewController.moveToEndOfLine() - } - - override func moveToBeginningOfParagraph(_ sender: Any?) { - textViewController.moveToBeginningOfParagraph() - } - - override func moveToEndOfParagraph(_ sender: Any?) { - textViewController.moveToEndOfParagraph() - } - - override func moveToBeginningOfDocument(_ sender: Any?) { - textViewController.moveToBeginningOfDocument() - } - - override func moveToEndOfDocument(_ sender: Any?) { - textViewController.moveToEndOfDocument() - } - - override func moveLeftAndModifySelection(_ sender: Any?) { - textViewController.moveLeftAndModifySelection() - } - - override func moveRightAndModifySelection(_ sender: Any?) { - textViewController.moveRightAndModifySelection() - } - - override func moveForwardAndModifySelection(_ sender: Any?) { - textViewController.moveRightAndModifySelection() - } - - override func moveBackwardAndModifySelection(_ sender: Any?) { - textViewController.moveLeftAndModifySelection() - } - - override func moveUpAndModifySelection(_ sender: Any?) { - textViewController.moveUpAndModifySelection() - } - - override func moveDownAndModifySelection(_ sender: Any?) { - textViewController.moveDownAndModifySelection() - } - - override func moveWordLeftAndModifySelection(_ sender: Any?) { - textViewController.moveWordLeftAndModifySelection() - } - - override func moveWordRightAndModifySelection(_ sender: Any?) { - textViewController.moveWordRightAndModifySelection() - } - - override func moveWordBackwardAndModifySelection(_ sender: Any?) { - textViewController.moveWordLeftAndModifySelection() - } - - override func moveWordForwardAndModifySelection(_ sender: Any?) { - textViewController.moveWordRightAndModifySelection() - } - - override func moveToBeginningOfLineAndModifySelection(_ sender: Any?) { - textViewController.moveToBeginningOfLineAndModifySelection() - } - - override func moveToEndOfLineAndModifySelection(_ sender: Any?) { - textViewController.moveToEndOfLineAndModifySelection() - } - - override func moveToBeginningOfParagraphAndModifySelection(_ sender: Any?) { - textViewController.moveToBeginningOfParagraphAndModifySelection() - } - - override func moveToEndOfParagraphAndModifySelection(_ sender: Any?) { - textViewController.moveToEndOfParagraphAndModifySelection() - } - - override func moveToBeginningOfDocumentAndModifySelection(_ sender: Any?) { - textViewController.moveToBeginningOfDocumentAndModifySelection() - } - - override func moveToEndOfDocumentAndModifySelection(_ sender: Any?) { - textViewController.moveToEndOfDocumentAndModifySelection() - } - - /// Copy the selected text. - /// - /// - Parameter sender: The object calling this method. - @objc func copy(_ sender: Any?) { - let selectedRange = selectedRange() - if selectedRange.length > 0, let text = textViewController.text(in: selectedRange) { - NSPasteboard.general.declareTypes([.string], owner: nil) - NSPasteboard.general.setString(text, forType: .string) - } - } - - /// Paste text from the pasteboard. - /// - /// - Parameter sender: The object calling this method. - @objc func paste(_ sender: Any?) { - let selectedRange = selectedRange() - if let string = NSPasteboard.general.string(forType: .string) { - let preparedText = textViewController.prepareTextForInsertion(string) - textViewController.replaceText(in: selectedRange, with: preparedText) - } - } - - /// Cut text to the pasteboard. - /// - /// - Parameter sender: The object calling this method. - @objc func cut(_ sender: Any?) { - let selectedRange = selectedRange() - if selectedRange.length > 0, let text = textViewController.text(in: selectedRange) { - NSPasteboard.general.setString(text, forType: .string) - textViewController.replaceText(in: selectedRange, with: "") - } - } - - /// Select all text in the text view. - /// - /// - Parameter sender: The object calling this method. - override func selectAll(_ sender: Any?) { - textViewController.selectedRange = NSRange(location: 0, length: textViewController.stringView.string.length) - } - - @objc func undo(_ sender: Any?) { - if let undoManager = undoManager, undoManager.canUndo { - undoManager.undo() - } - } - - @objc func redo(_ sender: Any?) { - if let undoManager = undoManager, undoManager.canRedo { - undoManager.redo() - } - } -} - // MARK: - Window private extension TextView { private func setupWindowObservers() { @@ -937,15 +696,6 @@ private extension TextView { } } -// MARK: - Location -private extension TextView { - private func locationClosestToPoint(in event: NSEvent) -> Int? { - let point = scrollContentView.convert(event.locationInWindow, from: nil) - let adjustedPoint = CGPoint(x: point.x - gutterWidth - textContainerInset.left, y: point.y) - return textViewController.layoutManager.closestIndex(to: adjustedPoint) - } -} - // MARK: - Menu private extension TextView { private func setupMenu() { diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index 61a0271a6..ce43aa684 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -5,15 +5,19 @@ import UIKit extension TextView: UITextInput {} public extension TextView { + /// The text position for the beginning of a document. var beginningOfDocument: UITextPosition { IndexedPosition(index: 0) } + /// The text position for the end of a document. var endOfDocument: UITextPosition { IndexedPosition(index: textViewController.stringView.string.length) } + /// Returns a Boolean value indicating whether the text view currently contains any text. var hasText: Bool { textViewController.stringView.string.length > 0 } + /// An input tokenizer that provides information about the granularity of text units. var tokenizer: UITextInputTokenizer { customTokenizer } @@ -21,6 +25,9 @@ public extension TextView { // MARK: - Caret public extension TextView { + /// Returns a rectangle to draw the caret at a specified insertion point. + /// - Parameter position: An object that identifies a location in a text input area. + /// - Returns: A rectangle that defines the area for drawing the caret. func caretRect(for position: UITextPosition) -> CGRect { guard let indexedPosition = position as? IndexedPosition else { fatalError("Expected position to be of type \(IndexedPosition.self)") @@ -35,6 +42,8 @@ public extension TextView { return caretFactory.caretRect(at: indexedPosition.index, allowMovingCaretToNextLineFragment: true) } + /// Called at the beginning of the gesture that the system uses to manipulate the cursor. + /// - Parameter point: The point at which the gesture occurred in your view. func beginFloatingCursor(at point: CGPoint) { guard floatingCaretView == nil, let position = closestPosition(to: point) else { return @@ -52,6 +61,8 @@ public extension TextView { editorDelegate?.textViewDidBeginFloatingCursor(self) } + /// Called to move the floating cursor to a new location. + /// - Parameter point: The new touch point in the underlying view. func updateFloatingCursor(at point: CGPoint) { if let floatingCaretView = floatingCaretView { let caretSize = floatingCaretView.frame.size @@ -60,6 +71,7 @@ public extension TextView { } } + /// Called at the end of the gesture that the system uses to manipulate the cursor. func endFloatingCursor() { insertionPointColor = insertionPointColorBeforeFloatingBegan updateCaretColor() @@ -67,18 +79,13 @@ public extension TextView { floatingCaretView = nil editorDelegate?.textViewDidEndFloatingCursor(self) } - - func updateCaretColor() { - // Removing the UITextSelectionView and re-adding it forces it to query the insertion point color. - if let textSelectionView = textSelectionView { - textSelectionView.removeFromSuperview() - addSubview(textSelectionView) - } - } } // MARK: - Editing public extension TextView { + /// Returns the text in the specified range. + /// - Parameter range: A range of text in a document. + /// - Returns: A substring of a document that falls within the specified range. func text(in range: UITextRange) -> String? { if let indexedRange = range as? IndexedRange { return textViewController.text(in: indexedRange.range) @@ -87,6 +94,10 @@ public extension TextView { } } + /// Replaces the text in a document that is in the specified range. + /// - Parameters: + /// - range: A range of text in a document. + /// - text: A string to replace the text in range. func replace(_ range: UITextRange, withText text: String) { let preparedText = textViewController.prepareTextForInsertion(text) guard let indexedRange = range as? IndexedRange else { @@ -98,6 +109,8 @@ public extension TextView { textViewController.replaceText(in: indexedRange.range.nonNegativeLength, with: preparedText) } + /// Inserts a character into the displayed text. + /// - Parameter text: A string object representing the character typed on the system keyboard. func insertText(_ text: String) { isRestoringPreviouslyDeletedText = hasDeletedTextWithPendingLayoutSubviews hasDeletedTextWithPendingLayoutSubviews = false @@ -121,6 +134,7 @@ public extension TextView { layoutIfNeeded() } + /// Deletes a character from the displayed text. func deleteBackward() { guard let selectedRange = textViewController.markedRange ?? textViewController.selectedRange, selectedRange.length > 0 else { return @@ -169,6 +183,7 @@ public extension TextView { // MARK: - Selection public extension TextView { + /// The range of selected text in a document. var selectedTextRange: UITextRange? { get { if let range = textViewController.selectedRange { @@ -205,6 +220,9 @@ public extension TextView { } } + /// Returns an array of selection rects corresponding to the range of text. + /// - Parameter range: An object representing a range in a document's text. + /// - Returns: An array of UITextSelectionRect objects that encompass the selection. func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { guard let indexedRange = range as? IndexedRange else { return [] @@ -231,12 +249,14 @@ public extension TextView { // MARK: - Marking public extension TextView { // swiftlint:disable unused_setter_value + /// A dictionary of attributes that describes how to draw marked text. var markedTextStyle: [NSAttributedString.Key: Any]? { get { nil } set {} } // swiftlint:enable unused_setter_value + /// The range of currently marked text in a document. var markedTextRange: UITextRange? { get { if let markedRange = textViewController.markedRange { @@ -250,6 +270,10 @@ public extension TextView { } } + /// Inserts the provided text and marks it to indicate that it is part of an active input session. + /// - Parameters: + /// - markedText: The text to be marked. + /// - selectedRange: A range within `markedText` that indicates the current selection. This range is always relative to `markedText`. func setMarkedText(_ markedText: String?, selectedRange: NSRange) { guard let range = textViewController.markedRange ?? textViewController.selectedRange else { return @@ -268,6 +292,7 @@ public extension TextView { removeAndAddEditableTextInteraction() } + /// Unmarks the currently marked text. func unmarkText() { inputDelegate?.selectionWillChange(self) textViewController.markedRange = nil @@ -278,6 +303,11 @@ public extension TextView { // MARK: - Ranges and Positions public extension TextView { + /// Returns the range between two text positions. + /// - Parameters: + /// - fromPosition: An object that represents a location in a document. + /// - toPosition: An object that represents another location in a document. + /// - Returns: An object that represents the range between `fromPosition` and `toPosition`. func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { guard let fromIndexedPosition = fromPosition as? IndexedPosition, let toIndexedPosition = toPosition as? IndexedPosition else { return nil @@ -286,6 +316,11 @@ public extension TextView { return IndexedRange(range) } + /// Returns the text position at a specified offset from another text position. + /// - Parameters: + /// - position: A custom UITextPosition object that represents a location in a document. + /// - offset: A character offset from position. It can be a positive or negative value. + /// - Returns: A custom UITextPosition object that represents the location in a document that is at the specified offset from position. func position(from position: UITextPosition, offset: Int) -> UITextPosition? { guard let indexedPosition = position as? IndexedPosition else { return nil @@ -297,6 +332,12 @@ public extension TextView { return IndexedPosition(index: newPosition) } + /// Returns the text position at a specified offset in a specified direction from another text position. + /// - Parameters: + /// - position: A custom UITextPosition object that represents a location in a document. + /// - direction: A UITextLayoutDirection constant that represents the direction of the offset from `position`. + /// - offset: A character offset from position. + /// - Returns: Returns the text position at a specified offset in a specified direction from another text position. func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? { guard let indexedPosition = position as? IndexedPosition else { return nil @@ -321,6 +362,11 @@ public extension TextView { } } + /// Returns how one text position compares to another text position. + /// - Parameters: + /// - position: A custom object that represents a location within a document. + /// - other: A custom object that represents another location within a document. + /// - Returns: A value that indicates whether the two text positions are identical or whether one is before the other. func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { guard let indexedPosition = position as? IndexedPosition, let otherIndexedPosition = other as? IndexedPosition else { #if targetEnvironment(macCatalyst) @@ -339,6 +385,11 @@ public extension TextView { } } + /// Returns the number of UTF-16 characters between one text position and another text position. + /// - Parameters: + /// - from: A custom object that represents a location within a document. + /// - toPosition: A custom object that represents another location within document. + /// - Returns: The number of UTF-16 characters between `fromPosition` and `toPosition`. func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { if let fromPosition = from as? IndexedPosition, let toPosition = toPosition as? IndexedPosition { return toPosition.index - fromPosition.index @@ -347,6 +398,11 @@ public extension TextView { } } + /// Returns the text position that is at the farthest extent in a specified layout direction within a range of text. + /// - Parameters: + /// - range: A text-range object that demarcates a range of text in a document. + /// - direction: A constant that indicates a direction of layout (right, left, up, down). + /// - Returns: A text-position object that identifies a location in the visible text. func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { // This implementation seems to match the behavior of UITextView. guard let indexedRange = range as? IndexedRange else { @@ -362,6 +418,11 @@ public extension TextView { } } + /// Returns a text range from a specified text position to its farthest extent in a certain direction of layout. + /// - Parameters: + /// - position: A text-position object that identifies a location in a document. + /// - direction: A constant that indicates a direction of layout (right, left, up, down). + /// - Returns: A text-range object that represents the distance from `position` to the farthest extent in `direction`. func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { // This implementation seems to match the behavior of UITextView. guard let indexedPosition = position as? IndexedPosition else { @@ -379,6 +440,9 @@ public extension TextView { } } + /// Returns the first rectangle that encloses a range of text in a document. + /// - Parameter range: An object that represents a range of text in a document. + /// - Returns: The first rectangle in a range of text. You might use this rectangle to draw a correction rectangle. The “first” in the name refers the rectangle enclosing the first line when the range encompasses multiple lines of text. func firstRect(for range: UITextRange) -> CGRect { guard let indexedRange = range as? IndexedRange else { fatalError("Expected range to be of type \(IndexedRange.self)") @@ -386,6 +450,9 @@ public extension TextView { return textViewController.layoutManager.firstRect(for: indexedRange.range) } + /// Returns the position in a document that is closest to a specified point. + /// - Parameter point: A point in the view that is drawing a document's text. + /// - Returns: An object locating a position in a document that is closest to `point`. func closestPosition(to point: CGPoint) -> UITextPosition? { if let index = textViewController.layoutManager.closestIndex(to: point) { return IndexedPosition(index: index) @@ -394,6 +461,11 @@ public extension TextView { } } + /// Returns the position in a document that is closest to a specified point in a specified range. + /// - Parameters: + /// - point: A point in the view that is drawing a document's text. + /// - range: An object representing a range in a document's text. + /// - Returns: An object representing the character position in range that is closest to `point`. func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { guard let indexedRange = range as? IndexedRange else { return nil @@ -407,6 +479,9 @@ public extension TextView { return IndexedPosition(index: cappedIndex) } + /// Returns the character or range of characters that is at a specified point in a document. + /// - Parameter point: A point in the view that is drawing a document's text. + /// - Returns: An object representing a range that encloses a character (or characters) at `point`. func characterRange(at point: CGPoint) -> UITextRange? { guard let index = textViewController.layoutManager.closestIndex(to: point) else { return nil @@ -419,10 +494,19 @@ public extension TextView { // MARK: - Writing Direction public extension TextView { + /// Returns the base writing direction for a position in the text going in a certain direction. + /// - Parameters: + /// - position: An object that identifies a location in a document. + /// - direction: A constant that indicates a direction of storage (forward or backward). + /// - Returns: A constant that represents a writing direction (for example, left-to-right or right-to-left). func baseWritingDirection(for position: UITextPosition, in direction: UITextStorageDirection) -> NSWritingDirection { .natural } + /// Sets the base writing direction for a specified range of text in a document. + /// - Parameters: + /// - writingDirection: A constant that represents a writing direction (for example, left-to-right or right-to-left) + /// - range: An object that represents a range of text in a document. func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) {} } #endif diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index bbaf2bc91..95c2a7f62 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -12,6 +12,7 @@ import UIKit /// When initially configuring the `TextView` with a theme, a language and the text to be shown, it is recommended to use the ``setState(_:addUndoAction:)`` function. /// The function takes an instance of ``TextViewState`` as input which can be created on a background queue to avoid blocking the main queue while doing the initial parse of a text. open class TextView: UIScrollView { + /// An input delegate that receives a notification when text changes or when the selection changes. @objc public weak var inputDelegate: UITextInputDelegate? /// Returns a Boolean value indicating whether this object can become the first responder. override public var canBecomeFirstResponder: Bool { @@ -470,7 +471,7 @@ 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 - /// A boolean value that enables a text view’s built-in find interaction. + /// 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. @available(iOS 16, *) @@ -482,7 +483,7 @@ open class TextView: UIScrollView { textSearchingHelper.isFindInteractionEnabled = newValue } } - /// The text view’s built-in find interaction. + /// The text view's built-in find interaction. /// /// Set to true to enable the text view's built-in find interaction. This method returns nil when the interaction isn't enabled. /// @@ -985,9 +986,9 @@ open class TextView: UIScrollView { /// Returns the farthest descendant of the receiver in the view hierarchy (including itself) that contains a specified point. /// - Parameters: - /// - point: A point specified in the receiver’s local coordinate system (bounds). + /// - point: A point specified in the receiver's local coordinate system (bounds). /// - event: The event that warranted a call to this method. If you are calling this method from outside your event-handling code, you may specify nil. - /// - Returns: The view object that is the farthest descendent of the current view and contains point. Returns nil if the point lies completely outside the receiver’s view hierarchy. + /// - Returns: The view object that is the farthest descendent of the current view and contains point. Returns nil if the point lies completely outside the receiver's view hierarchy. override open func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { guard isSelectable else { return nil @@ -1046,6 +1047,14 @@ extension TextView { } } } + + func updateCaretColor() { + // Removing the UITextSelectionView and re-adding it forces it to query the insertion point color. + if let textSelectionView = textSelectionView { + textSelectionView.removeFromSuperview() + addSubview(textSelectionView) + } + } } private extension TextView { From fdf153447f0f7b23ca9e86b0b019e4b378660365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 12:01:16 +0100 Subject: [PATCH 158/232] Improves formatting --- .../Runestone/TextView/Core/iOS/TextView_iOS.swift | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 95c2a7f62..8d4e59f16 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -541,18 +541,10 @@ open class TextView: UIScrollView { guard isEditable else { return false } - if let editorDelegate = editorDelegate { - return editorDelegate.textViewShouldBeginEditing(self) - } else { - return true - } + return editorDelegate??.textViewShouldBeginEditing(self) ?? true } private var shouldEndEditing: Bool { - if let editorDelegate = editorDelegate { - return editorDelegate.textViewShouldEndEditing(self) - } else { - return true - } + editorDelegate?.textViewShouldEndEditing(self) ?? true } /// Create a new text view. From b9bf48a206850c0f403e41775ad5c433d0b19a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 13:52:06 +0100 Subject: [PATCH 159/232] Improves scroller handling --- .../TextView/Core/ContentSizeService.swift | 9 +++++++- .../TextView/Core/LayoutManager.swift | 2 ++ .../TextView/Core/Mac/TextView_Mac.swift | 1 + .../TextViewController+ContentSize.swift | 23 +++++++++++++++++-- 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Sources/Runestone/TextView/Core/ContentSizeService.swift b/Sources/Runestone/TextView/Core/ContentSizeService.swift index a35425295..245d8defa 100644 --- a/Sources/Runestone/TextView/Core/ContentSizeService.swift +++ b/Sources/Runestone/TextView/Core/ContentSizeService.swift @@ -10,6 +10,13 @@ final class ContentSizeService { } } } + var verticalScrollerWidth: CGFloat = 0 { + didSet { + if verticalScrollerWidth != oldValue { + invalidateContentSize() + } + } + } var textContainerInset: MultiPlatformEdgeInsets = .zero var isLineWrappingEnabled = true { didSet { @@ -43,7 +50,7 @@ final class ContentSizeService { } } var contentWidth: CGFloat { - let minimumWidth = scrollViewSize.width - safeAreaInset.left - safeAreaInset.right + let minimumWidth = scrollViewSize.width - safeAreaInset.left - safeAreaInset.right - verticalScrollerWidth if isLineWrappingEnabled { return minimumWidth } else { diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index 5dc79fa16..71925c21e 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -15,6 +15,7 @@ final class LayoutManager { var lineManager: LineManager var stringView: StringView var scrollViewWidth: CGFloat = 0 + var verticalScrollerWidth: CGFloat = 0 var viewport: CGRect = .zero var languageMode: InternalLanguageMode { didSet { @@ -89,6 +90,7 @@ final class LayoutManager { - gutterWidthService.gutterWidth - textContainerInset.left - textContainerInset.right - safeAreaInsets.left - safeAreaInsets.right + - verticalScrollerWidth } 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. diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 96eb724d3..12fb502a8 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -518,6 +518,7 @@ open class TextView: NSView, NSMenuItemValidation { textViewController.scrollViewSize = scrollView.frame.size textViewController.layoutIfNeeded() textViewController.handleContentSizeUpdateIfNeeded() + textViewController.updateScrollerVisibility() updateCaretFrame() updateSelectedRectangles() } diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift index ecc82dd7d..40d141b2d 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift @@ -5,6 +5,7 @@ extension TextViewController { if scrollView.contentSize != contentSizeService.contentSize { hasPendingContentSizeUpdate = true handleContentSizeUpdateIfNeeded() + updateScrollerVisibility() } } @@ -31,11 +32,29 @@ extension TextViewController { scrollView.contentSize = contentSizeService.contentSize scrollView.contentOffset = oldContentOffset textView.setNeedsLayout() - #if os(macOS) + } + + #if os(macOS) + func updateScrollerVisibility() { + let hadVerticalScroller = scrollView.hasVerticalScroller + let hadHorizontalScroller = scrollView.hasHorizontalScroller scrollView.hasVerticalScroller = scrollView.contentSize.height > scrollView.frame.height scrollView.hasHorizontalScroller = scrollView.contentSize.width > scrollView.frame.width scrollView.horizontalScroller?.layer?.zPosition = 1_000 scrollView.verticalScroller?.layer?.zPosition = 1_000 - #endif + layoutManager.verticalScrollerWidth = scrollView.verticalScrollerWidth + contentSizeService.verticalScrollerWidth = scrollView.verticalScrollerWidth + if scrollView.hasVerticalScroller != hadVerticalScroller || scrollView.hasHorizontalScroller != hadHorizontalScroller { + textView.setNeedsLayout() + } + } + #endif +} + +#if os(macOS) +private extension MultiPlatformScrollView { + var verticalScrollerWidth: CGFloat { + hasVerticalScroller ? verticalScroller?.frame.width ?? 0 : 0 } } +#endif From 311941ec08f1e8619ce0debca1a8fe999ace821a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 15:27:44 +0100 Subject: [PATCH 160/232] Fixes errors compiling to iOS --- .../TextViewController+ContentSize.swift | 2 ++ .../Core/iOS/TextView_iOS+UITextInput.swift | 15 ++++----------- .../TextView/Core/iOS/TextView_iOS.swift | 7 +++---- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift index 40d141b2d..3d323a213 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift @@ -5,7 +5,9 @@ extension TextViewController { if scrollView.contentSize != contentSizeService.contentSize { hasPendingContentSizeUpdate = true handleContentSizeUpdateIfNeeded() + #if os(macOS) updateScrollerVisibility() + #endif } } diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index ce43aa684..7c6f2a1f4 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -454,11 +454,8 @@ public extension TextView { /// - Parameter point: A point in the view that is drawing a document's text. /// - Returns: An object locating a position in a document that is closest to `point`. func closestPosition(to point: CGPoint) -> UITextPosition? { - if let index = textViewController.layoutManager.closestIndex(to: point) { - return IndexedPosition(index: index) - } else { - return nil - } + let index = textViewController.layoutManager.closestIndex(to: point) + return IndexedPosition(index: index) } /// Returns the position in a document that is closest to a specified point in a specified range. @@ -470,9 +467,7 @@ public extension TextView { guard let indexedRange = range as? IndexedRange else { return nil } - guard let index = textViewController.layoutManager.closestIndex(to: point) else { - return nil - } + let index = textViewController.layoutManager.closestIndex(to: point) let minimumIndex = indexedRange.range.lowerBound let maximumIndex = indexedRange.range.upperBound let cappedIndex = min(max(index, minimumIndex), maximumIndex) @@ -483,9 +478,7 @@ public extension TextView { /// - Parameter point: A point in the view that is drawing a document's text. /// - Returns: An object representing a range that encloses a character (or characters) at `point`. func characterRange(at point: CGPoint) -> UITextRange? { - guard let index = textViewController.layoutManager.closestIndex(to: point) else { - return nil - } + let index = textViewController.layoutManager.closestIndex(to: point) let cappedIndex = max(index - 1, 0) let range = textViewController.stringView.string.customRangeOfComposedCharacterSequence(at: cappedIndex) return IndexedRange(range) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 8d4e59f16..3f5eb549f 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -541,7 +541,7 @@ open class TextView: UIScrollView { guard isEditable else { return false } - return editorDelegate??.textViewShouldBeginEditing(self) ?? true + return editorDelegate?.textViewShouldBeginEditing(self) ?? true } private var shouldEndEditing: Bool { editorDelegate?.textViewShouldEndEditing(self) ?? true @@ -1117,9 +1117,8 @@ private extension TextView { } let point = gestureRecognizer.location(in: self) let oldSelectedRange = selectedRange - if let index = textViewController.layoutManager.closestIndex(to: point) { - selectedRange = NSRange(location: index, length: 0) - } + let index = textViewController.layoutManager.closestIndex(to: point) + selectedRange = NSRange(location: index, length: 0) if selectedRange != oldSelectedRange { layoutIfNeeded() } From 5d72cc5e493f71ddd9c1d7a2cc627334d230e874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 15:41:43 +0100 Subject: [PATCH 161/232] Fixes filename --- Example/Example.xcodeproj/project.pbxproj | 8 ++++---- .../{CharacterPair.swift => BasicCharacterPair.swift} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename Example/MacExample/{CharacterPair.swift => BasicCharacterPair.swift} (100%) diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index d6d6bbfa4..c85c04a64 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 720F5F75298D1B8F00C64EC3 /* CharacterPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = 720F5F74298D1B8F00C64EC3 /* CharacterPair.swift */; }; + 720F5F75298D1B8F00C64EC3 /* BasicCharacterPair.swift in Sources */ = {isa = PBXBuildFile; fileRef = 720F5F74298D1B8F00C64EC3 /* BasicCharacterPair.swift */; }; 721103592989ACDD00DDFE48 /* RunestoneOneDarkTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 721103582989ACDD00DDFE48 /* RunestoneOneDarkTheme */; }; 7211035B2989ACDD00DDFE48 /* RunestonePlainTextTheme in Frameworks */ = {isa = PBXBuildFile; productRef = 7211035A2989ACDD00DDFE48 /* RunestonePlainTextTheme */; }; 7211035D2989ACDD00DDFE48 /* RunestoneThemeCommon in Frameworks */ = {isa = PBXBuildFile; productRef = 7211035C2989ACDD00DDFE48 /* RunestoneThemeCommon */; }; @@ -43,7 +43,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - 720F5F74298D1B8F00C64EC3 /* CharacterPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CharacterPair.swift; sourceTree = ""; }; + 720F5F74298D1B8F00C64EC3 /* BasicCharacterPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicCharacterPair.swift; sourceTree = ""; }; 7216EAC62829A16C001B6D39 /* Themes */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Themes; sourceTree = ""; }; 7243F9BA282D73E9005AAABF /* iOSExample.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = iOSExample.entitlements; sourceTree = ""; }; 729ECE4C2983F5B60049AFF5 /* MacExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -109,7 +109,7 @@ children = ( 729ECE572983F5B60049AFF5 /* MacExample.entitlements */, 729ECE4E2983F5B60049AFF5 /* AppDelegate.swift */, - 720F5F74298D1B8F00C64EC3 /* CharacterPair.swift */, + 720F5F74298D1B8F00C64EC3 /* BasicCharacterPair.swift */, 729ECE502983F5B60049AFF5 /* MainViewController.swift */, 729ECE522983F5B60049AFF5 /* Assets.xcassets */, 729ECE542983F5B60049AFF5 /* Main.storyboard */, @@ -372,7 +372,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 720F5F75298D1B8F00C64EC3 /* CharacterPair.swift in Sources */, + 720F5F75298D1B8F00C64EC3 /* BasicCharacterPair.swift in Sources */, 729ECE512983F5B60049AFF5 /* MainViewController.swift in Sources */, 729ECE4F2983F5B60049AFF5 /* AppDelegate.swift in Sources */, ); diff --git a/Example/MacExample/CharacterPair.swift b/Example/MacExample/BasicCharacterPair.swift similarity index 100% rename from Example/MacExample/CharacterPair.swift rename to Example/MacExample/BasicCharacterPair.swift From 5dbc1716c49b42308d1898740dcf128a90827804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 16:03:20 +0100 Subject: [PATCH 162/232] Adds syntaxNode(at:) --- .../Runestone/TextView/Core/Mac/TextView_Mac.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 12fb502a8..4609a93c6 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -555,6 +555,18 @@ open class TextView: NSView, NSMenuItemValidation { textViewController.setState(state, addUndoAction: addUndoAction) } + /// Returns the syntax node at the specified location in the document. + /// + /// This can be used with character pairs to determine if a pair should be inserted or not. + /// For example, a character pair consisting of two quotes (") to surround a string, should probably not be + /// inserted when the quote is typed while the caret is already inside a string. + /// + /// This requires a language to be set on the editor. + /// - Parameter location: A location in the document. + /// - Returns: The syntax node at the location. + public func syntaxNode(at location: Int) -> SyntaxNode? { + textViewController.syntaxNode(at: location) + } /// Implemented to override the default action of enabling or disabling a specific menu item. /// - Parameter menuItem: An NSMenuItem object that represents the menu item. /// - Returns: `true` to enable menuItem, `false` to disable it. From 78cf65fa224663b3f17fa76ca4d8f3b2ec76b1e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Fri, 17 Feb 2023 16:03:29 +0100 Subject: [PATCH 163/232] Adds text(in:) --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 4609a93c6..afbefe80b 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -567,6 +567,14 @@ open class TextView: NSView, NSMenuItemValidation { public func syntaxNode(at location: Int) -> SyntaxNode? { textViewController.syntaxNode(at: location) } + + /// Returns the text in the specified range. + /// - Parameter range: A range of text in the document. + /// - Returns: The substring that falls within the specified range. + public func text(in range: NSRange) -> String? { + textViewController.text(in: range) + } + /// Implemented to override the default action of enabling or disabling a specific menu item. /// - Parameter menuItem: An NSMenuItem object that represents the menu item. /// - Returns: `true` to enable menuItem, `false` to disable it. From f2efdbd529978742afdde6e2bf151a56c524c393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 18 Feb 2023 16:23:33 +0100 Subject: [PATCH 164/232] Fixes infinite loop --- Sources/Runestone/TextView/Navigation/StringTokenizer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Navigation/StringTokenizer.swift b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift index 2bcd45fee..e9b21902d 100644 --- a/Sources/Runestone/TextView/Navigation/StringTokenizer.swift +++ b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift @@ -202,7 +202,7 @@ private extension StringTokenizer { if isLocation(index, atBoundary: .word, inDirection: direction) { index = advanceIndex(index) } - while !isLocation(index, atBoundary: .word, inDirection: direction) && index >= 0 && index < stringView.string.length { + while !isLocation(index, atBoundary: .word, inDirection: direction) && index > 0 && index < stringView.string.length - 1 { index = advanceIndex(index) } return index From fc8c41d107f9690727a99916f0f05d31d60d5736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 18 Feb 2023 16:35:14 +0100 Subject: [PATCH 165/232] Update caret frame and selected rectangles when layoung out text view --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index afbefe80b..11497e9f4 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -527,6 +527,8 @@ open class TextView: NSView, NSMenuItemValidation { override public func layoutSubtreeIfNeeded() { super.layoutSubtreeIfNeeded() textViewController.layoutIfNeeded() + updateCaretFrame() + updateSelectedRectangles() } /// Informs the view that it has been added to a new view hierarchy. @@ -738,8 +740,6 @@ extension TextView: TextViewControllerDelegate { layoutIfNeeded() caretView.delayBlinkIfNeeded() updateCaretVisibility() - updateCaretFrame() - updateSelectedRectangles() scrollToVisibleLocationIfNeeded() } } From b856dc135bc4aa7bb04751faf660d85f7abf7479 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 18 Feb 2023 16:36:16 +0100 Subject: [PATCH 166/232] Call lineControllerDidInvalidateSize(_:) in redisplayLineFragments() --- .../Runestone/TextView/LineController/LineController.swift | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Sources/Runestone/TextView/LineController/LineController.swift b/Sources/Runestone/TextView/LineController/LineController.swift index 0ded4c05b..7dbb4ff54 100644 --- a/Sources/Runestone/TextView/LineController/LineController.swift +++ b/Sources/Runestone/TextView/LineController/LineController.swift @@ -13,7 +13,7 @@ typealias LineFragmentTree = RedBlackTree LineSyntaxHighlighter? - func lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ lineController: LineController) + func lineControllerDidInvalidateSize(_ lineController: LineController) } final class LineController { @@ -293,13 +293,9 @@ private extension LineController { if async { syntaxHighlighter.syntaxHighlight(input) { [weak self] result in if case .success = result, let self = self { - let oldWidth = self.lineWidth self.isSyntaxHighlightingInvalid = false self.isTypesetterInvalid = true self.redisplayLineFragments() - if abs(self.lineWidth - oldWidth) > CGFloat.ulpOfOne { - self.delegate?.lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(self) - } } } } else { @@ -374,6 +370,7 @@ private extension LineController { updateLineHeight(for: newLineFragments) reapplyLineFragmentToLineFragmentControllers() setNeedsDisplayOnLineFragmentViews() + delegate?.lineControllerDidInvalidateSize(self) } private func reapplyLineFragmentToLineFragmentControllers() { From 7c37a2c35682513553d6fe42dbe617ccf80f5dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sat, 18 Feb 2023 16:36:22 +0100 Subject: [PATCH 167/232] Force layout on macOS --- .../Core/TextViewController/TextViewController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 0cd599068..36e6b7898 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -747,9 +747,12 @@ extension TextViewController: LineControllerDelegate { return syntaxHighlighter } - func lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ lineController: LineController) { + func lineControllerDidInvalidateSize(_ lineController: LineController) { textView.setNeedsLayout() layoutManager.setNeedsLayout() + #if os(macOS) + textView.layoutIfNeeded() + #endif } } From 217f5ca9ef81187f6f1eefe9b1bef7cdfda3ac27 Mon Sep 17 00:00:00 2001 From: Joshua Halickman Date: Sat, 18 Feb 2023 10:38:30 -0500 Subject: [PATCH 168/232] Call editorDelegate textViewDidChange in the same place as the iOS TextView (#277) --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index afbefe80b..73354b719 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -732,6 +732,7 @@ extension TextView: TextViewControllerDelegate { func textViewControllerDidChangeText(_ textViewController: TextViewController) { caretView.delayBlinkIfNeeded() updateCaretFrame() + editorDelegate?.textViewDidChange(self) } func textViewController(_ textViewController: TextViewController, didChangeSelectedRange selectedRange: NSRange?) { From 7a84f3a765a335f0f6e6353411761097f9277f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helge=20He=C3=9F?= Date: Sat, 18 Feb 2023 16:43:57 +0100 Subject: [PATCH 169/232] Allow `TextView` setup from within IB (#278) Implements `-initWithCoder:`, seems to work fine for me. --- .../Runestone/TextView/Core/Mac/TextView_Mac.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index bf7414969..5674767f5 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -449,6 +449,14 @@ open class TextView: NSView, NSMenuItemValidation { /// Create a new text view. public init() { super.init(frame: .zero) + setup() + } + /// Create a new text view from a XIB or Storyboard. + public required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + private func setup() { textViewController.delegate = self textViewController.selectedRange = NSRange(location: 0, length: 0) scrollView.borderType = .noBorder @@ -466,11 +474,6 @@ open class TextView: NSView, NSMenuItemValidation { setupMenu() } - /// The initializer has not been implemented. - /// - Parameter coder: Not used. - public required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } deinit { NotificationCenter.default.removeObserver(self) From 41461e787529171cc24997a3a214334f4c1975ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 19 Feb 2023 18:13:52 +0100 Subject: [PATCH 170/232] Ensures line selection is computed based on new line heights --- Sources/Runestone/TextView/Core/LayoutManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index 71925c21e..dba1faecd 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -288,8 +288,8 @@ extension LayoutManager { CATransaction.begin() CATransaction.setDisableActions(true) layoutGutter() - layoutLineSelection() layoutLinesInViewport() + layoutLineSelection() updateLineNumberColors() CATransaction.commit() } From c0ffbe1c4c87f41d0bdc1e23006803d49b1f5c6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 19 Feb 2023 18:13:58 +0100 Subject: [PATCH 171/232] Ensures layout after setting state --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 5674767f5..12acc2f24 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -558,6 +558,8 @@ open class TextView: NSView, NSMenuItemValidation { /// - Parameter addUndoAction: Whether the state change can be undone. Defaults to false. public func setState(_ state: TextViewState, addUndoAction: Bool = false) { textViewController.setState(state, addUndoAction: addUndoAction) + setNeedsLayout() + layoutIfNeeded() } /// Returns the syntax node at the specified location in the document. From 26ba704ad1e3e1c82a09de30baa77a162453fba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 19 Feb 2023 19:42:24 +0100 Subject: [PATCH 172/232] Adds comment --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 12acc2f24..045857c25 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -558,6 +558,7 @@ open class TextView: NSView, NSMenuItemValidation { /// - Parameter addUndoAction: Whether the state change can be undone. Defaults to false. public func setState(_ state: TextViewState, addUndoAction: Bool = false) { textViewController.setState(state, addUndoAction: addUndoAction) + // Layout to ensure the selection erctangles and caret as correctly placed. setNeedsLayout() layoutIfNeeded() } From c241675ad2b410cdb61df09213d10aab19af8506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 19 Feb 2023 19:51:57 +0100 Subject: [PATCH 173/232] Fixes word movement --- .../TextView/Navigation/StringTokenizer.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Navigation/StringTokenizer.swift b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift index e9b21902d..76b47d965 100644 --- a/Sources/Runestone/TextView/Navigation/StringTokenizer.swift +++ b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift @@ -196,13 +196,21 @@ private extension StringTokenizer { case .backward: preferredIndex = index - 1 } - return min(max(preferredIndex, 0), stringView.string.length - 1) + return min(max(preferredIndex, 0), stringView.string.length) + } + func hasReachedEnd(at index: Int) -> Bool { + switch direction { + case .forward: + return index == stringView.string.length + case .backward: + return index == 0 + } } var index = location if isLocation(index, atBoundary: .word, inDirection: direction) { index = advanceIndex(index) } - while !isLocation(index, atBoundary: .word, inDirection: direction) && index > 0 && index < stringView.string.length - 1 { + while !isLocation(index, atBoundary: .word, inDirection: direction) && !hasReachedEnd(at: index) { index = advanceIndex(index) } return index From bea6db6357eedc8e86c5006eb4509de4aae6d614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 19 Feb 2023 19:57:42 +0100 Subject: [PATCH 174/232] Fixes whitespace --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 045857c25..bf57c0afd 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -451,11 +451,13 @@ open class TextView: NSView, NSMenuItemValidation { super.init(frame: .zero) setup() } + /// Create a new text view from a XIB or Storyboard. public required init?(coder: NSCoder) { super.init(coder: coder) setup() } + private func setup() { textViewController.delegate = self textViewController.selectedRange = NSRange(location: 0, length: 0) @@ -474,7 +476,6 @@ open class TextView: NSView, NSMenuItemValidation { setupMenu() } - deinit { NotificationCenter.default.removeObserver(self) } @@ -496,7 +497,6 @@ open class TextView: NSView, NSMenuItemValidation { return didBecomeFirstResponder } - /// Notifies the receiver that it's been asked to relinquish its status as first responder in its window. @discardableResult override open func resignFirstResponder() -> Bool { From 60e3d9de65065a017fb88334bd30e5f7f9db7a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 19 Feb 2023 20:07:01 +0100 Subject: [PATCH 175/232] Fixes attempting to delete past last character --- .../Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift index ae48b5f3c..a2d13019d 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift @@ -7,6 +7,9 @@ public extension TextView { guard var selectedRange = textViewController.markedRange ?? textViewController.selectedRange?.nonNegativeLength else { return } + guard selectedRange.location > 0 || selectedRange.length > 0 else { + return + } if selectedRange.length == 0 { selectedRange.location -= 1 selectedRange.length = 1 From 144bfba8d94a148551bfe56ccfee54752b92c99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 19 Feb 2023 20:20:14 +0100 Subject: [PATCH 176/232] Adds deleteForward(_:) --- .../Core/Mac/TextView_Mac+Commands.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift index a2d13019d..cf12fb582 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift @@ -2,6 +2,22 @@ import AppKit public extension TextView { + /// Deletes a character from the displayed text. + override func deleteForward(_ sender: Any?) { + guard let selectedRange = textViewController.selectedRange else { + return + } + guard selectedRange.length == 0 else { + deleteBackward(nil) + return + } + guard selectedRange.location < textViewController.stringView.string.length else { + return + } + textViewController.selectedRange = NSRange(location: selectedRange.location, length: 1) + deleteBackward(nil) + } + /// Deletes a character from the displayed text. override func deleteBackward(_ sender: Any?) { guard var selectedRange = textViewController.markedRange ?? textViewController.selectedRange?.nonNegativeLength else { From 3224951d3a24869fe1bd7d2be49a60b3613998b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 19 Feb 2023 20:20:28 +0100 Subject: [PATCH 177/232] Supports deleting words --- .../Core/Mac/TextView_Mac+Commands.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift index cf12fb582..87f01cc2e 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift @@ -118,5 +118,46 @@ public extension TextView { undoManager.redo() } } + + /// Delete the word in front of the insertion point. + override func deleteWordForward(_ sender: Any?) { + deleteText(toBoundary: .word, inDirection: .forward) + } + + /// Delete the word behind the insertion point. + override func deleteWordBackward(_ sender: Any?) { + deleteText(toBoundary: .word, inDirection: .backward) + } +} + +private extension TextView { + private func deleteText(toBoundary boundary: TextBoundary, inDirection direction: TextDirection) { + guard let selectedRange = textViewController.selectedRange else { + return + } + guard selectedRange.length == 0 else { + deleteBackward(nil) + return + } + guard let range = rangeForDeleting(from: selectedRange.location, toBoundary: boundary, inDirection: direction) else { + return + } + textViewController.selectedRange = range + deleteBackward(nil) + } + + private func rangeForDeleting(from sourceLocation: Int, toBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> NSRange? { + let stringTokenizer = StringTokenizer( + stringView: textViewController.stringView, + lineManager: textViewController.lineManager, + lineControllerStorage: textViewController.lineControllerStorage + ) + guard let destinationLocation = stringTokenizer.location(from: sourceLocation, toBoundary: boundary, inDirection: direction) else { + return nil + } + let lowerBound = min(sourceLocation, destinationLocation) + let upperBound = max(sourceLocation, destinationLocation) + return NSRange(location: lowerBound, length: upperBound - lowerBound) + } } #endif From ef83371f316d55107f02acf5f97cb94b3e9af87f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Sun, 19 Feb 2023 20:21:14 +0100 Subject: [PATCH 178/232] Updates documentation --- Sources/Runestone/Documentation.docc/Extensions/TextView.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/Documentation.docc/Extensions/TextView.md b/Sources/Runestone/Documentation.docc/Extensions/TextView.md index f59810731..13c7c535a 100644 --- a/Sources/Runestone/Documentation.docc/Extensions/TextView.md +++ b/Sources/Runestone/Documentation.docc/Extensions/TextView.md @@ -146,13 +146,14 @@ - ``smartDashesType`` - ``smartInsertDeleteType`` - ``smartQuotesType`` -- ``text(in:)-3lp4v`` +- ``text(in:)`` - ``insertText(_:)`` - ``insertText(_:replacementRange:)`` - ``insertNewline(_:)`` - ``insertTab(_:)`` - ``replaceText(in:)`` - ``replace(_:withText:)-7gret`` +- ``deleteForward(_:)`` - ``deleteBackward()`` - ``deleteBackward(_:)`` - ``undoManager`` @@ -239,6 +240,8 @@ - ``selectAll(_:)`` - ``replace(_:withText:)-7cbas`` - ``canPerformAction(_:withSender:)`` +- ``deleteWordForward(_:)`` +- ``deleteWordBackward(_:)`` ### Responder Chain From e1a3fc13a99f25e82353bc763e748fafe5b99525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20St=C3=B8vring?= Date: Mon, 20 Feb 2023 19:36:52 +0100 Subject: [PATCH 179/232] Fixes scroll performance --- Sources/Runestone/Documentation.docc/Extensions/TextView.md | 2 +- Sources/Runestone/MultiPlatform/MultiPlatformView.swift | 4 +--- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 6 +++--- .../Core/TextViewController/TextViewController.swift | 3 --- 4 files changed, 5 insertions(+), 10 deletions(-) diff --git a/Sources/Runestone/Documentation.docc/Extensions/TextView.md b/Sources/Runestone/Documentation.docc/Extensions/TextView.md index 13c7c535a..1777f29b4 100644 --- a/Sources/Runestone/Documentation.docc/Extensions/TextView.md +++ b/Sources/Runestone/Documentation.docc/Extensions/TextView.md @@ -12,12 +12,12 @@ - ``isFlipped`` - ``didMoveToWindow()`` +- ``layout()`` - ``layoutSubviews()`` - ``safeAreaInsetsDidChange()`` - ``traitCollectionDidChange(_:)`` - ``viewDidMoveToWindow()`` - ``resizeSubviews(withOldSize:)`` -- ``layoutSubtreeIfNeeded()`` - ``resetCursorRects()`` ### Responding to Text View Changes diff --git a/Sources/Runestone/MultiPlatform/MultiPlatformView.swift b/Sources/Runestone/MultiPlatform/MultiPlatformView.swift index 288be1d4e..01b11f4a2 100644 --- a/Sources/Runestone/MultiPlatform/MultiPlatformView.swift +++ b/Sources/Runestone/MultiPlatform/MultiPlatformView.swift @@ -32,9 +32,7 @@ extension NSView { needsLayout = true } - func layoutIfNeeded() { - layoutSubtreeIfNeeded() - } + func layoutIfNeeded() {} } func UIGraphicsGetCurrentContext() -> CGContext? { diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index bf57c0afd..b5485e6a5 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -526,9 +526,9 @@ open class TextView: NSView, NSMenuItemValidation { updateSelectedRectangles() } - /// Updates the layout of the receiving view and its subviews based on the current views and constraints. - override public func layoutSubtreeIfNeeded() { - super.layoutSubtreeIfNeeded() + /// Perform layout in concert with the constraint-based layout system. + open override func layout() { + super.layout() textViewController.layoutIfNeeded() updateCaretFrame() updateSelectedRectangles() diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index 36e6b7898..fa725640d 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -750,9 +750,6 @@ extension TextViewController: LineControllerDelegate { func lineControllerDidInvalidateSize(_ lineController: LineController) { textView.setNeedsLayout() layoutManager.setNeedsLayout() - #if os(macOS) - textView.layoutIfNeeded() - #endif } } From 74b66a76cf6d4967ea0712bda51aa6bc2617427d Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 7 Oct 2024 00:34:24 +0300 Subject: [PATCH 180/232] Add Runestone editor to Mac app. --- Example/Example.xcodeproj/project.pbxproj | 8 +++--- Package.swift | 33 ++++------------------- 2 files changed, 9 insertions(+), 32 deletions(-) diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index c85c04a64..aa1e2581a 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -430,7 +430,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 8NQFWJHC63; + DEVELOPMENT_TEAM = 6ZSJ45TPYC; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -460,7 +460,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 8NQFWJHC63; + DEVELOPMENT_TEAM = 6ZSJ45TPYC; ENABLE_HARDENED_RUNTIME = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; @@ -604,7 +604,7 @@ CODE_SIGN_ENTITLEMENTS = iOSExample/iOSExample.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 8NQFWJHC63; + DEVELOPMENT_TEAM = 6ZSJ45TPYC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iOSExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -634,7 +634,7 @@ CODE_SIGN_ENTITLEMENTS = iOSExample/iOSExample.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 8NQFWJHC63; + DEVELOPMENT_TEAM = 6ZSJ45TPYC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iOSExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/Package.swift b/Package.swift index d476ffa1e..e48f79396 100644 --- a/Package.swift +++ b/Package.swift @@ -6,40 +6,17 @@ import PackageDescription let package = Package( name: "Runestone", defaultLocalization: "en", - platforms: [ - .iOS(.v14), - .macOS(.v11) - ], + platforms: [.iOS(.v14), .macOS(.v11)], products: [ .library(name: "Runestone", targets: ["Runestone"]) ], + dependencies: [ + .package(url: "https://github.com/tree-sitter/tree-sitter.git", .upToNextMinor(from: "0.20.9")), + ], targets: [ .target(name: "Runestone", - dependencies: ["TreeSitter"], + dependencies: [.product(name: "TreeSitter", package: "tree-sitter")], resources: [.process("TextView/Appearance/Theme.xcassets")]), - .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"]) ] From dcae9b1678341d114a45aa271ef6df50b5bb1d3c Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 14 Oct 2024 23:31:15 +0300 Subject: [PATCH 181/232] Fixes to first responder state. Fix forced unwrap of line for character. Allow setting selected range. Disable fatal error in node location calculation. --- Sources/Runestone/RedBlackTree/RedBlackTree.swift | 1 + .../TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift | 5 +++++ Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 4 ++++ .../Runestone/TextView/TextSelection/CaretRectFactory.swift | 2 +- 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/RedBlackTree/RedBlackTree.swift b/Sources/Runestone/RedBlackTree/RedBlackTree.swift index 0d174e495..4c7091f70 100644 --- a/Sources/Runestone/RedBlackTree/RedBlackTree.swift +++ b/Sources/Runestone/RedBlackTree/RedBlackTree.swift @@ -36,6 +36,7 @@ final class RedBlackTree Node? { guard location >= minimumValue && location <= root.nodeTotalValue else { #if DEBUG + return nil fatalError("\(location) is out of bounds. Valid range is \(minimumValue) - \(root.nodeTotalValue)." + " This issue is under investigation. Please open an issue at https://github.com/simonbs/Runestone/issues" + " and include this stack trace and a sample text file if possible. This fatal error is only thrown in debug builds.") diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift index 7da05f623..69ca8dae0 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift @@ -15,6 +15,11 @@ extension TextView: NSTextInputClient { textViewController.selectedRange?.nonNegativeLength ?? NSRange(location: 0, length: 0) } + /// Set current selection range of the text view. + public func setSelectedRange(_ range: NSRange?) { + textViewController.selectedRange = range + } + /// Inserts the given string into the receiver, replacing the specified content. /// - Parameters: /// - string: The text to insert. diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index b5485e6a5..1a02cf58a 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -538,6 +538,7 @@ open class TextView: NSView, NSMenuItemValidation { override public func viewDidMoveToWindow() { super.viewDidMoveToWindow() textViewController.performFullLayoutIfNeeded() + windowKeyStateDidChange() } /// Overridden by subclasses to define their default cursor rectangles. @@ -561,6 +562,9 @@ open class TextView: NSView, NSMenuItemValidation { // Layout to ensure the selection erctangles and caret as correctly placed. setNeedsLayout() layoutIfNeeded() + + // Resign first responder here because maintaining it does not work + resignFirstResponder() } /// Returns the syntax node at the specified location in the document. diff --git a/Sources/Runestone/TextView/TextSelection/CaretRectFactory.swift b/Sources/Runestone/TextView/TextSelection/CaretRectFactory.swift index 328592b79..38d16da48 100644 --- a/Sources/Runestone/TextView/TextSelection/CaretRectFactory.swift +++ b/Sources/Runestone/TextView/TextSelection/CaretRectFactory.swift @@ -24,7 +24,7 @@ final class CaretRectFactory { func caretRect(at location: Int, allowMovingCaretToNextLineFragment: Bool) -> CGRect { let leadingLineSpacing = gutterWidthService.gutterWidth + textContainerInset.left let safeLocation = min(max(location, 0), stringView.string.length) - let line = lineManager.line(containingCharacterAt: safeLocation)! + guard let line = lineManager.line(containingCharacterAt: safeLocation) else { return .zero } let lineController = lineControllerStorage.getOrCreateLineController(for: line) let lineLocalLocation = safeLocation - line.location if allowMovingCaretToNextLineFragment && shouldMoveCaretToNextLineFragment(forLocation: lineLocalLocation, in: line) { From 6b7775f549f4dbea8249ced940928706d3ea4eba Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 14 Oct 2024 23:52:31 +0300 Subject: [PATCH 182/232] Fix adjusted position when finding closest index to point in layout manager. --- Sources/Runestone/TextView/Core/LayoutManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index dba1faecd..d08979474 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -247,7 +247,7 @@ extension LayoutManager { } func closestIndex(to point: CGPoint) -> Int { - let adjustedXPosition = point.x - textContainerInset.left + let adjustedXPosition = point.x let adjustedYPosition = point.y - textContainerInset.top let adjustedPoint = CGPoint(x: adjustedXPosition, y: adjustedYPosition) if let line = lineManager.line(containingYOffset: adjustedPoint.y), let lineController = lineControllerStorage[line.id] { From 844988938c2aa9fd87e09219633e7cec76d81950 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Tue, 15 Oct 2024 10:13:46 +0300 Subject: [PATCH 183/232] Increase caret width to 2 points and fix getting caret x position. --- Sources/Runestone/Library/Caret.swift | 2 +- Sources/Runestone/TextView/LineController/LineController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/Library/Caret.swift b/Sources/Runestone/Library/Caret.swift index da84e3c8c..75823f1e4 100644 --- a/Sources/Runestone/Library/Caret.swift +++ b/Sources/Runestone/Library/Caret.swift @@ -4,7 +4,7 @@ enum Caret { #if os(iOS) static let width: CGFloat = 2 #else - static let width: CGFloat = 1 + static let width: CGFloat = 2 #endif static func defaultHeight(for font: MultiPlatformFont?) -> CGFloat { diff --git a/Sources/Runestone/TextView/LineController/LineController.swift b/Sources/Runestone/TextView/LineController/LineController.swift index 7dbb4ff54..77242456d 100644 --- a/Sources/Runestone/TextView/LineController/LineController.swift +++ b/Sources/Runestone/TextView/LineController/LineController.swift @@ -425,7 +425,7 @@ extension LineController { if let caretLocation = lineFragment.caretLocation(forLineLocalLocation: lineLocalLocation) { let xPosition = CTLineGetOffsetForStringIndex(lineFragment.line, caretLocation, nil) let yPosition = lineFragment.yPosition + (lineFragment.scaledSize.height - lineFragment.baseSize.height) / 2 - return CGRect(x: xPosition, y: yPosition, width: Caret.width, height: lineFragment.baseSize.height) + return CGRect(x: xPosition - Caret.width / 2, y: yPosition, width: Caret.width, height: lineFragment.baseSize.height) } } let yPosition = (estimatedLineFragmentHeight * lineFragmentHeightMultiplier - estimatedLineFragmentHeight) / 2 From f45351beceaf7a93b75f72743f31ea12e9b906df Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Tue, 15 Oct 2024 22:56:55 +0300 Subject: [PATCH 184/232] Update key window state when view moves to a new superview, such as when leaving window. --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 1a02cf58a..adfb2400e 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -540,7 +540,12 @@ open class TextView: NSView, NSMenuItemValidation { textViewController.performFullLayoutIfNeeded() windowKeyStateDidChange() } - + + override public func viewWillMove(toSuperview newSuperview: NSView?) { + super.viewWillMove(toSuperview: newSuperview) + windowKeyStateDidChange() + } + /// Overridden by subclasses to define their default cursor rectangles. override public func resetCursorRects() { super.resetCursorRects() From 7aa7fea3730b5069e7d2b5981deac2cab627b5b6 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Thu, 17 Oct 2024 12:41:14 +0300 Subject: [PATCH 185/232] Do not resign first responder status in Runestone when state is updated. Resign first responder status when window loses key status as this does not happen reliably otherwise. --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index adfb2400e..14dfe6379 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -567,9 +567,6 @@ open class TextView: NSView, NSMenuItemValidation { // Layout to ensure the selection erctangles and caret as correctly placed. setNeedsLayout() layoutIfNeeded() - - // Resign first responder here because maintaining it does not work - resignFirstResponder() } /// Returns the syntax node at the specified location in the document. @@ -631,6 +628,11 @@ private extension TextView { @objc private func windowKeyStateDidChange() { isWindowKey = window?.isKeyWindow ?? false + + // First responder status is not updated when window changes key + if !isWindowKey { + resignFirstResponder() + } } } From 3db5677219b4fc44bac1dce0b60f071b2a422123 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Thu, 17 Oct 2024 13:08:05 +0300 Subject: [PATCH 186/232] Do not resign first responder status on window key status change. --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 14dfe6379..f75f095c4 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -501,6 +501,7 @@ open class TextView: NSView, NSMenuItemValidation { @discardableResult override open func resignFirstResponder() -> Bool { guard isEditing && shouldEndEditing else { + print("resignFirstResponder guard return false") return false } let didResignFirstResponder = super.resignFirstResponder() @@ -509,6 +510,7 @@ open class TextView: NSView, NSMenuItemValidation { textViewController.isEditing = false editorDelegate?.textViewDidEndEditing(self) } + print("resignFirstResponder didResignFirstResponder \(didResignFirstResponder)") return didResignFirstResponder } @@ -628,11 +630,6 @@ private extension TextView { @objc private func windowKeyStateDidChange() { isWindowKey = window?.isKeyWindow ?? false - - // First responder status is not updated when window changes key - if !isWindowKey { - resignFirstResponder() - } } } @@ -675,10 +672,12 @@ private extension TextView { private func updateCaretVisibility() { if isWindowKey && isFirstResponder && selectedRange().length == 0 { + print("caret isHidden = false isWindowKey = \(isWindowKey) isFirstResponder = \(isFirstResponder)") caretView.isHidden = false caretView.isBlinkingEnabled = true caretView.delayBlinkIfNeeded() } else { + print("caret isHidden = true isWindowKey = \(isWindowKey) isFirstResponder = \(isFirstResponder)") caretView.isHidden = true caretView.isBlinkingEnabled = false } From 942c7f35801f946df890a1635b5f9a63b1106d89 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Thu, 17 Oct 2024 14:17:11 +0300 Subject: [PATCH 187/232] Remove debugging code. --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index f75f095c4..226729470 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -501,7 +501,6 @@ open class TextView: NSView, NSMenuItemValidation { @discardableResult override open func resignFirstResponder() -> Bool { guard isEditing && shouldEndEditing else { - print("resignFirstResponder guard return false") return false } let didResignFirstResponder = super.resignFirstResponder() @@ -510,7 +509,6 @@ open class TextView: NSView, NSMenuItemValidation { textViewController.isEditing = false editorDelegate?.textViewDidEndEditing(self) } - print("resignFirstResponder didResignFirstResponder \(didResignFirstResponder)") return didResignFirstResponder } @@ -672,12 +670,10 @@ private extension TextView { private func updateCaretVisibility() { if isWindowKey && isFirstResponder && selectedRange().length == 0 { - print("caret isHidden = false isWindowKey = \(isWindowKey) isFirstResponder = \(isFirstResponder)") caretView.isHidden = false caretView.isBlinkingEnabled = true caretView.delayBlinkIfNeeded() } else { - print("caret isHidden = true isWindowKey = \(isWindowKey) isFirstResponder = \(isFirstResponder)") caretView.isHidden = true caretView.isBlinkingEnabled = false } From a88455a55e9af3241e61d12a706896d7a19284f6 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Thu, 17 Oct 2024 15:05:45 +0300 Subject: [PATCH 188/232] Do not use previous operation as it breaks moving between empty lines. --- .../StatefulLineNavigationLocationFactory.swift | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift index e549222ef..3d4981ed7 100644 --- a/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift +++ b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift @@ -44,6 +44,13 @@ final class StatefulLineNavigationLocationFactory { } func location(movingFrom location: Int, byLineCount offset: Int = 1, inDirection direction: TextDirection) -> Int { + let operation = operation(movingFrom: location, byLineCount: offset, inDirection: direction) + previousOperation = operation + return operation.destinationLocation + + /* + * Do not use previous operation as it breaks moving between empty lines + if let previousOperation { let directionedOffset = DirectionedOffset(offset: offset, inDirection: direction) let newDirectionedOffset = previousOperation.offset + directionedOffset @@ -61,6 +68,7 @@ final class StatefulLineNavigationLocationFactory { previousOperation = operation return operation.destinationLocation } + */ } func reset() { From 7f1ef50f6b6b004bfa9601e6ac42abcee3c463b9 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Thu, 17 Oct 2024 15:14:39 +0300 Subject: [PATCH 189/232] Set caret width back to 1 to not cause other selection rect issues. --- Sources/Runestone/Library/Caret.swift | 2 +- Sources/Runestone/TextView/LineController/LineController.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/Library/Caret.swift b/Sources/Runestone/Library/Caret.swift index 75823f1e4..da84e3c8c 100644 --- a/Sources/Runestone/Library/Caret.swift +++ b/Sources/Runestone/Library/Caret.swift @@ -4,7 +4,7 @@ enum Caret { #if os(iOS) static let width: CGFloat = 2 #else - static let width: CGFloat = 2 + static let width: CGFloat = 1 #endif static func defaultHeight(for font: MultiPlatformFont?) -> CGFloat { diff --git a/Sources/Runestone/TextView/LineController/LineController.swift b/Sources/Runestone/TextView/LineController/LineController.swift index 77242456d..7dbb4ff54 100644 --- a/Sources/Runestone/TextView/LineController/LineController.swift +++ b/Sources/Runestone/TextView/LineController/LineController.swift @@ -425,7 +425,7 @@ extension LineController { if let caretLocation = lineFragment.caretLocation(forLineLocalLocation: lineLocalLocation) { let xPosition = CTLineGetOffsetForStringIndex(lineFragment.line, caretLocation, nil) let yPosition = lineFragment.yPosition + (lineFragment.scaledSize.height - lineFragment.baseSize.height) / 2 - return CGRect(x: xPosition - Caret.width / 2, y: yPosition, width: Caret.width, height: lineFragment.baseSize.height) + return CGRect(x: xPosition, y: yPosition, width: Caret.width, height: lineFragment.baseSize.height) } } let yPosition = (estimatedLineFragmentHeight * lineFragmentHeightMultiplier - estimatedLineFragmentHeight) / 2 From c130933117f9a137fa2fe09a3b471d60bfa9cae0 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Thu, 17 Oct 2024 18:34:14 +0300 Subject: [PATCH 190/232] Change caret width back to 2 points. --- 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 da84e3c8c..75823f1e4 100644 --- a/Sources/Runestone/Library/Caret.swift +++ b/Sources/Runestone/Library/Caret.swift @@ -4,7 +4,7 @@ enum Caret { #if os(iOS) static let width: CGFloat = 2 #else - static let width: CGFloat = 1 + static let width: CGFloat = 2 #endif static func defaultHeight(for font: MultiPlatformFont?) -> CGFloat { From 15d7f359d267cad0768f1558bd6d10d65ac8f635 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Fri, 25 Oct 2024 17:17:19 +0300 Subject: [PATCH 191/232] Allow text view to become first responder even when it is not editable. Prevent text input client commands and inserting text when text view is not editable. --- .../Mac/TextView_Mac+NSTextInputClient.swift | 9 +++-- .../TextView/Core/Mac/TextView_Mac.swift | 40 +++++++++---------- 2 files changed, 25 insertions(+), 24 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift index 69ca8dae0..5bc1702b8 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift @@ -4,9 +4,9 @@ import AppKit extension TextView: NSTextInputClient { // swiftlint:disable:next prohibited_super_call override public func doCommand(by selector: Selector) { -// #if DEBUG -// print(NSStringFromSelector(selector)) -// #endif + guard isEditable else { + return + } super.doCommand(by: selector) } @@ -25,6 +25,9 @@ extension TextView: NSTextInputClient { /// - string: The text to insert. /// - replacementRange: The range of content to replace in the receiver's text storage. public func insertText(_ string: Any, replacementRange: NSRange) { + guard isEditable else { + return + } guard let string = string as? String else { return } diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 226729470..2c0c80110 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -36,7 +36,14 @@ open class TextView: NSView, NSMenuItemValidation { } /// Whether the text view is in a state where the contents can be edited. public var isEditing: Bool { - textViewController.isEditing + get { + textViewController.isEditing + } + set { + if newValue != isEditing { + updateCaretVisibility() + } + } } /// The text that the text view displays. public var text: String { @@ -421,13 +428,8 @@ open class TextView: NSView, NSMenuItemValidation { } } } - private var isFirstResponder = false { - didSet { - if isFirstResponder != oldValue { - updateCaretVisibility() - } - } - } + private var isFirstResponder = false + private var shouldBeginEditing: Bool { guard isEditable else { return false @@ -483,33 +485,29 @@ open class TextView: NSView, NSMenuItemValidation { /// Notifies the receiver that it's about to become first responder in its NSWindow. @discardableResult override open func becomeFirstResponder() -> Bool { - guard !isEditing && shouldBeginEditing else { + guard super.becomeFirstResponder() else { return false } - let didBecomeFirstResponder = super.becomeFirstResponder() - if didBecomeFirstResponder { - isFirstResponder = true + isFirstResponder = true + if shouldBeginEditing { textViewController.isEditing = true editorDelegate?.textViewDidBeginEditing(self) - } else { - textViewController.isEditing = false } - return didBecomeFirstResponder + return true } /// Notifies the receiver that it's been asked to relinquish its status as first responder in its window. @discardableResult override open func resignFirstResponder() -> Bool { - guard isEditing && shouldEndEditing else { + guard super.resignFirstResponder() else { return false } - let didResignFirstResponder = super.resignFirstResponder() - if didResignFirstResponder { - isFirstResponder = false + isFirstResponder = false + if shouldEndEditing { textViewController.isEditing = false editorDelegate?.textViewDidEndEditing(self) } - return didResignFirstResponder + return true } /// Informs the view's subviews that the view's bounds rectangle size has changed. @@ -669,7 +667,7 @@ private extension TextView { } private func updateCaretVisibility() { - if isWindowKey && isFirstResponder && selectedRange().length == 0 { + if isWindowKey && isEditing && selectedRange().length == 0 { caretView.isHidden = false caretView.isBlinkingEnabled = true caretView.delayBlinkIfNeeded() From fd464d077329746723c248136cf3144a0aac8b47 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Fri, 25 Oct 2024 17:21:16 +0300 Subject: [PATCH 192/232] Do not perform keyboard commands when text view is not editable. --- .../Core/Mac/TextView_Mac+Commands.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift index 87f01cc2e..2202aac7e 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift @@ -4,6 +4,9 @@ import AppKit public extension TextView { /// Deletes a character from the displayed text. override func deleteForward(_ sender: Any?) { + guard isEditable else { + return + } guard let selectedRange = textViewController.selectedRange else { return } @@ -20,6 +23,9 @@ public extension TextView { /// Deletes a character from the displayed text. override func deleteBackward(_ sender: Any?) { + guard isEditable else { + return + } guard var selectedRange = textViewController.markedRange ?? textViewController.selectedRange?.nonNegativeLength else { return } @@ -52,6 +58,9 @@ public extension TextView { /// Inserts a newline character. override func insertNewline(_ sender: Any?) { + guard isEditable else { + return + } if textViewController.shouldChangeText(in: textViewController.rangeForInsertingText, replacementText: lineEndings.symbol) { textViewController.indentController.insertLineBreak(in: textViewController.rangeForInsertingText, using: lineEndings.symbol) } @@ -59,6 +68,9 @@ public extension TextView { /// Inserts a tab character. override func insertTab(_ sender: Any?) { + guard isEditable else { + return + } let indentString = indentStrategy.string(indentLevel: 1) if textViewController.shouldChangeText(in: textViewController.rangeForInsertingText, replacementText: indentString) { textViewController.replaceText(in: textViewController.rangeForInsertingText, with: indentString) @@ -80,6 +92,9 @@ public extension TextView { /// /// - Parameter sender: The object calling this method. @objc func paste(_ sender: Any?) { + guard isEditable else { + return + } let selectedRange = selectedRange() if let string = NSPasteboard.general.string(forType: .string) { let preparedText = textViewController.prepareTextForInsertion(string) @@ -91,6 +106,9 @@ public extension TextView { /// /// - Parameter sender: The object calling this method. @objc func cut(_ sender: Any?) { + guard isEditable else { + return + } let selectedRange = selectedRange() if selectedRange.length > 0, let text = textViewController.text(in: selectedRange) { NSPasteboard.general.setString(text, forType: .string) @@ -107,6 +125,9 @@ public extension TextView { /// Performs the undo operations in the last undo group. @objc func undo(_ sender: Any?) { + guard isEditable else { + return + } if let undoManager = undoManager, undoManager.canUndo { undoManager.undo() } @@ -114,6 +135,9 @@ public extension TextView { /// Performs the operations in the last group on the redo stack. @objc func redo(_ sender: Any?) { + guard isEditable else { + return + } if let undoManager = undoManager, undoManager.canRedo { undoManager.redo() } @@ -121,17 +145,26 @@ public extension TextView { /// Delete the word in front of the insertion point. override func deleteWordForward(_ sender: Any?) { + guard isEditable else { + return + } deleteText(toBoundary: .word, inDirection: .forward) } /// Delete the word behind the insertion point. override func deleteWordBackward(_ sender: Any?) { + guard isEditable else { + return + } deleteText(toBoundary: .word, inDirection: .backward) } } private extension TextView { private func deleteText(toBoundary boundary: TextBoundary, inDirection direction: TextDirection) { + guard isEditable else { + return + } guard let selectedRange = textViewController.selectedRange else { return } From ea7ffbe89a977a5bb1f01bc79a73dbbe268f92d2 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Fri, 25 Oct 2024 17:38:35 +0300 Subject: [PATCH 193/232] Disable editing menu items when text view is not editable. --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 2c0c80110..25adf8223 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -591,16 +591,18 @@ open class TextView: NSView, NSMenuItemValidation { /// - Parameter menuItem: An NSMenuItem object that represents the menu item. /// - Returns: `true` to enable menuItem, `false` to disable it. public func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { - if menuItem.action == #selector(copy(_:)) || menuItem.action == #selector(cut(_:)) { + if menuItem.action == #selector(copy(_:)) { return selectedRange().length > 0 + } else if menuItem.action == #selector(cut(_:)) { + return isEditable && selectedRange().length > 0 } else if menuItem.action == #selector(paste(_:)) { - return NSPasteboard.general.canReadItem(withDataConformingToTypes: [UTType.plainText.identifier]) + return isEditable && NSPasteboard.general.canReadItem(withDataConformingToTypes: [UTType.plainText.identifier]) } else if menuItem.action == #selector(selectAll(_:)) { return !text.isEmpty } else if menuItem.action == #selector(undo(_:)) { - return undoManager?.canUndo ?? false + return isEditable && undoManager?.canUndo ?? false } else if menuItem.action == #selector(redo(_:)) { - return undoManager?.canRedo ?? false + return isEditable && undoManager?.canRedo ?? false } else { return true } From df2f224543c1bc7adc2c7209fb60581c81bd7515 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Fri, 25 Oct 2024 18:37:42 +0300 Subject: [PATCH 194/232] Add key equivalents to menu items and add Select All item. --- Sources/Runestone/Library/L10n.swift | 2 ++ Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 8 +++++--- .../TextView/SearchAndReplace/TextFinderClient.swift | 0 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 Sources/Runestone/TextView/SearchAndReplace/TextFinderClient.swift diff --git a/Sources/Runestone/Library/L10n.swift b/Sources/Runestone/Library/L10n.swift index 9de89f01b..18f583337 100644 --- a/Sources/Runestone/Library/L10n.swift +++ b/Sources/Runestone/Library/L10n.swift @@ -20,6 +20,8 @@ internal enum L10n { internal static let paste = L10n.tr("Localizable", "menu.item_title.paste", fallback: "Copy") /// Replace internal static let replace = L10n.tr("Localizable", "menu.item_title.replace", fallback: "Replace") + /// Select All + internal static let selectAll = L10n.tr("Localizable", "menu.item_title.selectAll", fallback: "Select All") } } internal enum Undo { diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 25adf8223..9b92ad3ef 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -735,9 +735,11 @@ private extension TextView { private extension TextView { private func setupMenu() { menu = NSMenu() - menu?.addItem(withTitle: L10n.Menu.ItemTitle.cut, action: #selector(cut(_:)), keyEquivalent: "") - menu?.addItem(withTitle: L10n.Menu.ItemTitle.copy, action: #selector(copy(_:)), keyEquivalent: "") - menu?.addItem(withTitle: L10n.Menu.ItemTitle.paste, action: #selector(paste(_:)), keyEquivalent: "") + menu?.addItem(withTitle: L10n.Menu.ItemTitle.cut, action: #selector(cut(_:)), keyEquivalent: "x") + menu?.addItem(withTitle: L10n.Menu.ItemTitle.copy, action: #selector(copy(_:)), keyEquivalent: "c") + menu?.addItem(withTitle: L10n.Menu.ItemTitle.paste, action: #selector(paste(_:)), keyEquivalent: "v") + menu?.addItem(.separator()) + menu?.addItem(withTitle: L10n.Menu.ItemTitle.selectAll, action: #selector(selectAll(_:)), keyEquivalent: "a") } } diff --git a/Sources/Runestone/TextView/SearchAndReplace/TextFinderClient.swift b/Sources/Runestone/TextView/SearchAndReplace/TextFinderClient.swift new file mode 100644 index 000000000..e69de29bb From eaaf71154c809c97034066731346a107b30a2049 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 26 Oct 2024 01:34:33 +0300 Subject: [PATCH 195/232] Update isEditing variable in text view instead of text view controller, so that we can update caret visibility. --- Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 9b92ad3ef..0caf77575 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -41,6 +41,7 @@ open class TextView: NSView, NSMenuItemValidation { } set { if newValue != isEditing { + textViewController.isEditing = newValue updateCaretVisibility() } } @@ -490,7 +491,7 @@ open class TextView: NSView, NSMenuItemValidation { } isFirstResponder = true if shouldBeginEditing { - textViewController.isEditing = true + isEditing = true editorDelegate?.textViewDidBeginEditing(self) } return true @@ -504,7 +505,7 @@ open class TextView: NSView, NSMenuItemValidation { } isFirstResponder = false if shouldEndEditing { - textViewController.isEditing = false + isEditing = false editorDelegate?.textViewDidEndEditing(self) } return true From 2d7739999728c1c643e48f7ae7f7d94960564242 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 26 Oct 2024 13:37:48 +0300 Subject: [PATCH 196/232] Add separate selection highlight color and unemphasized selection highlight color and use them according to first responder status. Perform layout of text view after first responder status changes. --- .../TextView/Core/Mac/TextView_Mac.swift | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 0caf77575..cfffab0a8 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -401,13 +401,19 @@ open class TextView: NSView, NSMenuItemValidation { } } } - /// The color of the selection highlight. It is most common to set this to the same color as the color used for the insertion point. - public var selectionHighlightColor: NSColor = .label.withAlphaComponent(0.2) { + /// The color of the selection highlight. + public var selectionHighlightColor: NSColor = .selectedTextBackgroundColor { didSet { - if selectionHighlightColor != oldValue { - for (_, view) in selectionViewReuseQueue.visibleViews { - view.backgroundColor = selectionHighlightColor - } + if selectionHighlightColor != oldValue && isFirstResponder { + updateSelectedRectangles() + } + } + } + /// The color of the selection highlight when view is not first responder. + public var unemphasizedSelectionHighlightColor: NSColor = .unemphasizedSelectedTextBackgroundColor { + didSet { + if unemphasizedSelectionHighlightColor != oldValue && !isFirstResponder { + updateSelectedRectangles() } } } @@ -429,7 +435,13 @@ open class TextView: NSView, NSMenuItemValidation { } } } - private var isFirstResponder = false + private var isFirstResponder = false { + didSet { + if isFirstResponder != oldValue { + needsLayout = true + } + } + } private var shouldBeginEditing: Bool { guard isEditable else { @@ -723,8 +735,8 @@ private extension TextView { let view = selectionViewReuseQueue.dequeueView(forKey: key) view.frame = selectionRect.rect view.wantsLayer = true - view.backgroundColor = selectionHighlightColor - scrollContentView.addSubview(view) + view.backgroundColor = isFirstResponder ? selectionHighlightColor : unemphasizedSelectionHighlightColor + scrollContentView.addSubview(view, positioned: .below, relativeTo: nil) appearedViewKeys.insert(key) } let disappearedViewKeys = Set(selectionViewReuseQueue.visibleViews.keys).subtracting(appearedViewKeys) From 3ddddd778bb21aeaa46e4eb07e04edbc84867699 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 26 Oct 2024 19:16:01 +0300 Subject: [PATCH 197/232] Update colors from theme in layout manager when doing layout update. --- Sources/Runestone/TextView/Core/LayoutManager.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index d08979474..07dbdac30 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -540,6 +540,11 @@ extension LayoutManager { lineNumberView.textColor = theme.lineNumberColor } } + gutterBackgroundView.backgroundColor = theme.gutterBackgroundColor + gutterBackgroundView.hairlineColor = theme.gutterHairlineColor + invisibleCharacterConfiguration.textColor = theme.invisibleCharactersColor + gutterSelectionBackgroundView.backgroundColor = theme.selectedLinesGutterBackgroundColor + lineSelectionBackgroundView.backgroundColor = theme.selectedLineBackgroundColor } private func setupViewHierarchy() { From 0b305e7fa111808ac23aa65cb1c564dbc63bebc9 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 30 Nov 2024 20:48:41 +0200 Subject: [PATCH 198/232] Fix issue when preparing text for insertion. When editor was set to CRLF line endings, and pasted text already contained CRLF endings, this function would not apply line ending changes correctly. Here we first split the text separating by any kind of new line character and then join them with the correct line ending. --- Sources/Runestone/TextView/Core/TextInputView.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextInputView.swift b/Sources/Runestone/TextView/Core/TextInputView.swift index a7b2f2c10..97e03154f 100644 --- a/Sources/Runestone/TextView/Core/TextInputView.swift +++ b/Sources/Runestone/TextView/Core/TextInputView.swift @@ -1320,12 +1320,8 @@ extension TextInputView { private func prepareTextForInsertion(_ text: String) -> String { // Ensure all line endings match our preferred line endings. - var preparedText = text - let lineEndingsToReplace: [LineEnding] = [.crlf, .cr, .lf].filter { $0 != lineEndings } - for lineEnding in lineEndingsToReplace { - preparedText = preparedText.replacingOccurrences(of: lineEnding.symbol, with: lineEndings.symbol) - } - return preparedText + let lines = text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + return lines.joined(separator: lineEndings.symbol) } } From 5769c082f837cd8728826ccb0f886a50b0ed9337 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 30 Nov 2024 20:58:40 +0200 Subject: [PATCH 199/232] Fix preparing text for insertion. --- .../TextViewController/TextViewController+Editing.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift index c01616826..575eedd91 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift @@ -78,12 +78,8 @@ extension TextViewController { func prepareTextForInsertion(_ text: String) -> String { // Ensure all line endings match our preferred line endings. - var preparedText = text - let lineEndingsToReplace: [LineEnding] = [.crlf, .cr, .lf].filter { $0 != lineEndings } - for lineEnding in lineEndingsToReplace { - preparedText = preparedText.replacingOccurrences(of: lineEnding.symbol, with: lineEndings.symbol) - } - return preparedText + let lines = text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + return lines.joined(separator: lineEndings.symbol) } func shouldChangeText(in range: NSRange, replacementText text: String) -> Bool { From e4b7fe723767337c165fbb00eefbe32936a11bd9 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 9 Dec 2024 15:23:45 +0200 Subject: [PATCH 200/232] Allow minimising all windows in Mac app. --- Sources/Runestone/TextView/SearchAndReplace/Untitled.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 Sources/Runestone/TextView/SearchAndReplace/Untitled.swift diff --git a/Sources/Runestone/TextView/SearchAndReplace/Untitled.swift b/Sources/Runestone/TextView/SearchAndReplace/Untitled.swift new file mode 100644 index 000000000..e69de29bb From 59e95514b6e0975d1329009f9aabee7d1dc0a0e6 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 13 Jan 2025 19:02:15 +0200 Subject: [PATCH 201/232] Set needsLayoutLineSelection to false when performing layout. --- Sources/Runestone/TextView/Core/LayoutManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index 07dbdac30..f900d4e0c 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -301,7 +301,7 @@ extension LayoutManager { func layoutLineSelectionIfNeeded() { if needsLayoutLineSelection { - needsLayoutLineSelection = true + needsLayoutLineSelection = false CATransaction.begin() CATransaction.setDisableActions(false) layoutLineSelection() From 2e88cc399c8ebd54b3e46cccb417e6e216427dbe Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Wed, 15 Jan 2025 12:36:10 +0200 Subject: [PATCH 202/232] Fix flashing of gutter background between light and dark appearance by not updating color here. --- Sources/Runestone/TextView/Core/LayoutManager.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index f900d4e0c..db36a1f31 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -540,7 +540,10 @@ extension LayoutManager { lineNumberView.textColor = theme.lineNumberColor } } - gutterBackgroundView.backgroundColor = theme.gutterBackgroundColor + + // Not setting the background color here seems to fix between light and dark appearance + // gutterBackgroundView.backgroundColor = theme.gutterBackgroundColor + gutterBackgroundView.hairlineColor = theme.gutterHairlineColor invisibleCharacterConfiguration.textColor = theme.invisibleCharactersColor gutterSelectionBackgroundView.backgroundColor = theme.selectedLinesGutterBackgroundColor From e874c71daaa83ef3d79f0f0c5127c8b84e1f3543 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Wed, 15 Jan 2025 12:36:44 +0200 Subject: [PATCH 203/232] Only apply theme to children when set to a different value. --- .../TextView/Core/TextViewController/TextViewController.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index fa725640d..caa2d7515 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -200,7 +200,9 @@ final class TextViewController { var lineEndings: LineEnding = .lf var theme: Theme = DefaultTheme() { didSet { - applyThemeToChildren() + if theme !== oldValue { + applyThemeToChildren() + } } } var characterPairs: [CharacterPair] = [] { From 53e7ac90bdfa1c6f5419d5feb09fe75b15088182 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 3 Mar 2025 15:19:41 +0200 Subject: [PATCH 204/232] Remove isFirstResponder property from TextView as it falls out of sync with actual first responder status. Make it a computed property based on window's firstResponder variable instead. Set needs layout flag whenever becoming or resigning first responder. Update TextView isEditing status whenever isEditable changes and TextView is already first responder. --- .../TextView/Core/Mac/TextView_Mac.swift | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index cfffab0a8..eaf90b5d3 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -31,6 +31,11 @@ open class TextView: NSView, NSMenuItemValidation { set { if newValue != isEditable { textViewController.isEditable = newValue + + if isEditable && isFirstResponder && shouldBeginEditing { + isEditing = true + editorDelegate?.textViewDidBeginEditing(self) + } } } } @@ -435,11 +440,9 @@ open class TextView: NSView, NSMenuItemValidation { } } } - private var isFirstResponder = false { - didSet { - if isFirstResponder != oldValue { - needsLayout = true - } + private var isFirstResponder: Bool { + get { + window?.firstResponder == self } } @@ -501,7 +504,7 @@ open class TextView: NSView, NSMenuItemValidation { guard super.becomeFirstResponder() else { return false } - isFirstResponder = true + needsLayout = true if shouldBeginEditing { isEditing = true editorDelegate?.textViewDidBeginEditing(self) @@ -515,7 +518,7 @@ open class TextView: NSView, NSMenuItemValidation { guard super.resignFirstResponder() else { return false } - isFirstResponder = false + needsLayout = true if shouldEndEditing { isEditing = false editorDelegate?.textViewDidEndEditing(self) From 4e2e0ec9eec2a92afd6baf28aebb43983c6655e2 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 3 Mar 2025 17:49:31 +0200 Subject: [PATCH 205/232] Move isFirstResponder computed property to NSView extension so that it is available everywhere. Move updating editing state when editable status changes to TextViewController for consistency. --- .../Runestone/MultiPlatform/MultiPlatformView.swift | 4 ++++ Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift | 10 ---------- .../Core/TextViewController/TextViewController.swift | 5 +++++ 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Sources/Runestone/MultiPlatform/MultiPlatformView.swift b/Sources/Runestone/MultiPlatform/MultiPlatformView.swift index 01b11f4a2..7e7899394 100644 --- a/Sources/Runestone/MultiPlatform/MultiPlatformView.swift +++ b/Sources/Runestone/MultiPlatform/MultiPlatformView.swift @@ -33,6 +33,10 @@ extension NSView { } func layoutIfNeeded() {} + + var isFirstResponder: Bool { + window?.firstResponder == self + } } func UIGraphicsGetCurrentContext() -> CGContext? { diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index eaf90b5d3..91086d801 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -31,11 +31,6 @@ open class TextView: NSView, NSMenuItemValidation { set { if newValue != isEditable { textViewController.isEditable = newValue - - if isEditable && isFirstResponder && shouldBeginEditing { - isEditing = true - editorDelegate?.textViewDidBeginEditing(self) - } } } } @@ -440,11 +435,6 @@ open class TextView: NSView, NSMenuItemValidation { } } } - private var isFirstResponder: Bool { - get { - window?.firstResponder == self - } - } private var shouldBeginEditing: Bool { guard isEditable else { diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index caa2d7515..ba6e7eaa8 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -61,6 +61,11 @@ final class TextViewController { } var isEditable = true { didSet { + if isEditable != oldValue && isEditable && textView.isFirstResponder { + isEditing = true + textView.editorDelegate?.textViewDidBeginEditing(textView) + } + if isEditable != oldValue && !isEditable && isEditing { textView.resignFirstResponder() isEditing = false From 0e6406e4933d9a38a461355f9bdf7bf67759bae6 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Tue, 25 Mar 2025 11:15:20 +0200 Subject: [PATCH 206/232] Add keyboard navigation handling for scroll to beginning and end. --- .../Core/Mac/TextView_Mac+KeyboardNavigation.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardNavigation.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardNavigation.swift index 8323bc1aa..b14dd79a8 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardNavigation.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardNavigation.swift @@ -50,6 +50,11 @@ public extension TextView { textViewController.moveRightAndModifySelection() } + /// Move the insertion pointer to the beginning of the document. + override func scrollToBeginningOfDocument(_ sender: Any?) { + textViewController.moveToBeginningOfDocument() + } + /// Move the insertion pointer to the beginning of the document. override func moveToBeginningOfDocument(_ sender: Any?) { textViewController.moveToBeginningOfDocument() @@ -80,6 +85,11 @@ public extension TextView { textViewController.moveToBeginningOfParagraphAndModifySelection() } + /// Move the insertion pointer to the end of the document. + override func scrollToEndOfDocument(_ sender: Any?) { + textViewController.moveToEndOfDocument() + } + /// Move the insertion pointer to the end of the document. override func moveToEndOfDocument(_ sender: Any?) { textViewController.moveToEndOfDocument() From ca24a42208328c8249f8b1607c5a91c4bea6fdf7 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sun, 16 Nov 2025 22:21:24 +0200 Subject: [PATCH 207/232] Add search and replace support to TextView on Mac. Implement new FindPanel that shows search UI. --- Package.resolved | 14 + .../TextView/Appearance/DefaultTheme.swift | 14 + .../Runestone/TextView/Appearance/Theme.swift | 21 + .../TextView/Core/Mac/TextView_Mac+Find.swift | 106 +++++ .../TextView/Core/Mac/TextView_Mac.swift | 114 ++++++ .../TextView/Core/TextViewDelegate.swift | 2 +- .../SearchAndReplace/Mac/FindController.swift | 362 ++++++++++++++++++ .../SearchAndReplace/Mac/FindPanel.swift | 343 +++++++++++++++++ 8 files changed, 975 insertions(+), 1 deletion(-) create mode 100644 Package.resolved create mode 100644 Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift create mode 100644 Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift create mode 100644 Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 000000000..e415caea8 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "tree-sitter", + "kind" : "remoteSourceControl", + "location" : "https://github.com/tree-sitter/tree-sitter.git", + "state" : { + "revision" : "98be227227af10cc7a269cb3ffb23686c0610b17", + "version" : "0.20.9" + } + } + ], + "version" : 2 +} diff --git a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift index 8f04f890d..bf04955f8 100644 --- a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift +++ b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift @@ -1,6 +1,9 @@ #if os(iOS) import UIKit +#elseif os(macOS) +import AppKit #endif +import Foundation /// Default theme used by Runestone when no other theme has been set. public final class DefaultTheme: Runestone.Theme { @@ -85,5 +88,16 @@ public final class DefaultTheme: Runestone.Theme { return nil } } +#elseif os(macOS) + @available(macOS 12, *) + public func highlightedRange(forFoundTextRange foundTextRange: NSRange, isSelected: Bool) -> HighlightedRange? { + if isSelected { + let color = MultiPlatformColor(themeColorNamed: "search_match_highlighted") + return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 2) + } else { + let color = MultiPlatformColor(themeColorNamed: "search_match_found") + return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 2) + } + } #endif } diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index 3e3fdaf10..aebce30b8 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -67,6 +67,18 @@ 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? +#elseif os(macOS) + /// Highlighted range for a text range matching a search query. + /// + /// This function is called when highlighting a search result. + /// + /// Return `nil` to prevent highlighting the range. + /// - Parameters: + /// - foundTextRange: The text range matching a search query. + /// - isSelected: Whether this is the currently selected match (true) or just a found match (false). + /// - Returns: The object used for highlighting the provided text range, or `nil` if the range should not be highlighted. + @available(macOS 12, *) + func highlightedRange(forFoundTextRange foundTextRange: NSRange, isSelected: Bool) -> HighlightedRange? #endif } @@ -117,5 +129,14 @@ public extension Theme { return nil } } +#elseif os(macOS) + @available(macOS 12, *) + func highlightedRange(forFoundTextRange foundTextRange: NSRange, isSelected: Bool) -> HighlightedRange? { + if isSelected { + return HighlightedRange(range: foundTextRange, color: .systemYellow) + } else { + return HighlightedRange(range: foundTextRange, color: .systemYellow.withAlphaComponent(0.2)) + } + } #endif } diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift new file mode 100644 index 000000000..9d1297155 --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift @@ -0,0 +1,106 @@ +#if os(macOS) +import AppKit + +// MARK: - NSTextFinder Bridge (Available on all macOS versions) +extension TextView { + /// Bridge NSTextFinder actions to custom find implementation + @objc override public func performTextFinderAction(_ sender: Any?) { + guard #available(macOS 12, *) else { + super.performTextFinderAction(sender) + return + } + + guard let menuItem = sender as? NSMenuItem else { + super.performTextFinderAction(sender) + return + } + + guard let action = NSTextFinder.Action(rawValue: menuItem.tag) else { + super.performTextFinderAction(sender) + return + } + + switch action { + case .showFindInterface: + showFindPanel(sender) + case .showReplaceInterface: + showFindPanel(sender) + case .nextMatch: + findNext(sender) + case .previousMatch: + findPrevious(sender) + case .replace, .replaceAndFind: + // Show find panel - replace functionality is available there + showFindPanel(sender) + case .replaceAll: + // Show find panel - replace all functionality is available there + showFindPanel(sender) + case .hideFindInterface: + hideFindPanel(sender) + case .setSearchString: + useSelectionForFind(sender) + default: + super.performTextFinderAction(sender) + } + } +} + +// MARK: - Find Panel (macOS 12+) +@available(macOS 12, *) +extension TextView { + private var findController: FindController { + FindController.shared + } + + /// Shows the find panel + @objc public func showFindPanel(_ sender: Any?) { + findController.textView = self + findController.showFindPanel() + } + + /// Hides the find panel + @objc public func hideFindPanel(_ sender: Any?) { + findController.hideFindPanel() + } + + /// Finds the next occurrence + @objc public func findNext(_ sender: Any?) { + findController.textView = self + findController.findNext() + } + + /// Finds the previous occurrence + @objc public func findPrevious(_ sender: Any?) { + findController.textView = self + findController.findPrevious() + } + + /// Refreshes the find panel search results. Call this when the text content changes. + @objc public func refreshFindPanelSearch() { + if findController.textView === self { + // This text view is being searched - refresh the search + findController.refreshSearch() + } else { + // This text view is not being searched - just clear any old highlights + highlightedRanges.removeAll() + } + } + + /// Called when this text view becomes first responder + internal func notifyFindControllerDidBecomeFocused() { + // Update the find controller to search this text view + findController.textView = self + } + + /// Uses the current selection as the search string + @objc public func useSelectionForFind(_ sender: Any?) { + findController.textView = self + let range = selectedRange() + if let selection = text(in: range), !selection.isEmpty { + findController.showFindPanel() + findController.setSearchString(selection) + } + } +} + +#endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 91086d801..089ad9263 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -499,6 +499,10 @@ open class TextView: NSView, NSMenuItemValidation { isEditing = true editorDelegate?.textViewDidBeginEditing(self) } + // Notify find controller that this text view is now focused + if #available(macOS 12, *) { + notifyFindControllerDidBecomeFocused() + } return true } @@ -593,6 +597,95 @@ open class TextView: NSView, NSMenuItemValidation { textViewController.text(in: range) } + /// Search for the specified query in the text view. + /// + /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:)``. + /// + /// ```swift + /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) + /// let results = textView.search(for: query) + /// ``` + /// + /// - Parameter query: Query to find matches for. + /// - Returns: Results matching the query. + public func search(for query: SearchQuery) -> [SearchResult] { + let searchController = SearchController(stringView: textViewController.stringView) + searchController.delegate = self + return searchController.search(for: query) + } + + /// Search for the specified query and return results that take a replacement string into account. + /// + /// When searching for a regular expression this function will perform pattern matching and take the matched groups into account in the returned results. + /// + /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:replacingMatchesWith:)`` and how the returned search results can be used to perform a replace operation. + /// + /// ```swift + /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) + /// let results = textView.search(for: query, replacingMatchesWith: "bar") + /// let replacements = results.map { BatchReplaceSet.Replacement(range: $0.range, text: $0.replacementText) } + /// let batchReplaceSet = BatchReplaceSet(replacements: replacements) + /// textView.replaceText(in: batchReplaceSet) + /// ``` + /// + /// - Parameters: + /// - query: Query to find matches for. + /// - replacementString: String to replace matches with. Can refer to groups in a regular expression using $0, $1, $2 etc. + /// - Returns: Results matching the query. + public func search(for query: SearchQuery, replacingMatchesWith replacementString: String) -> [SearchReplaceResult] { + let searchController = SearchController(stringView: textViewController.stringView) + searchController.delegate = self + return searchController.search(for: query, replacingMatchesWith: replacementString) + } + + /// Replaces the text in the specified matches. + /// - Parameters: + /// - batchReplaceSet: Set of ranges to replace with a text. + public func replaceText(in batchReplaceSet: BatchReplaceSet) { + textViewController.replaceText(in: batchReplaceSet) + } + + /// Returns a peek into the text view's underlying attributed string. + /// - Parameter range: Range of text to include in text view. The returned result may span a larger range than the one specified. + /// - Returns: Text preview containing the specified range. + public func textPreview(containing range: NSRange) -> TextPreview? { + textViewController.layoutManager.textPreview(containing: range) + } + + /// Selects a highlighted range behind the selected range if possible. + public func selectPreviousHighlightedRange() { + textViewController.highlightNavigationController.selectPreviousRange() + } + + /// Selects a highlighted range after the selected range if possible. + public func selectNextHighlightedRange() { + textViewController.highlightNavigationController.selectNextRange() + } + + /// Selects the highlighed range at the specified index. + /// - Parameter index: Index of highlighted range to select. + public func selectHighlightedRange(at index: Int) { + textViewController.highlightNavigationController.selectRange(at: index) + } + + /// Scrolls the text view to reveal the text in the specified range. + /// + /// The function will scroll the text view as little as possible while revealing as much as possible of the specified range. It is not guaranteed that the entire range is visible after performing the scroll. + /// + /// - Parameters: + /// - range: The range of text to scroll into view. + public func scrollRangeToVisible(_ range: NSRange) { + textViewController.scrollRangeToVisible(range) + } + + /// Replaces the text that is in the specified range. + /// - Parameters: + /// - range: A range of text in the document. + /// - text: A string to replace the text in range. + public func replace(_ range: NSRange, withText text: String) { + textViewController.replaceText(in: range, with: text) + } + /// Implemented to override the default action of enabling or disabling a specific menu item. /// - Parameter menuItem: An NSMenuItem object that represents the menu item. /// - Returns: `true` to enable menuItem, `false` to disable it. @@ -609,6 +702,14 @@ open class TextView: NSView, NSMenuItemValidation { return isEditable && undoManager?.canUndo ?? false } else if menuItem.action == #selector(redo(_:)) { return isEditable && undoManager?.canRedo ?? false + } else if #available(macOS 12, *), menuItem.action == #selector(showFindPanel(_:)) { + return true + } else if #available(macOS 12, *), menuItem.action == #selector(findNext(_:)) || menuItem.action == #selector(findPrevious(_:)) { + // These are enabled if there are search results + return true + } else if menuItem.action == #selector(performTextFinderAction(_:)) { + // Enable NSTextFinder actions (bridged to custom find panel on macOS 12+) + return true } else { return true } @@ -746,6 +847,12 @@ private extension TextView { menu?.addItem(withTitle: L10n.Menu.ItemTitle.paste, action: #selector(paste(_:)), keyEquivalent: "v") menu?.addItem(.separator()) menu?.addItem(withTitle: L10n.Menu.ItemTitle.selectAll, action: #selector(selectAll(_:)), keyEquivalent: "a") + if #available(macOS 12, *) { + menu?.addItem(.separator()) + menu?.addItem(withTitle: "Find...", action: #selector(showFindPanel(_:)), keyEquivalent: "f") + menu?.addItem(withTitle: "Find Next", action: #selector(findNext(_:)), keyEquivalent: "g") + menu?.addItem(withTitle: "Find Previous", action: #selector(findPrevious(_:)), keyEquivalent: "G") + } } } @@ -764,4 +871,11 @@ extension TextView: TextViewControllerDelegate { scrollToVisibleLocationIfNeeded() } } + +// MARK: - SearchControllerDelegate +extension TextView: SearchControllerDelegate { + func searchController(_ searchController: SearchController, linePositionAt location: Int) -> LinePosition? { + textViewController.lineManager.linePosition(at: location) + } +} #endif diff --git a/Sources/Runestone/TextView/Core/TextViewDelegate.swift b/Sources/Runestone/TextView/Core/TextViewDelegate.swift index c096cdc7d..2600632a1 100644 --- a/Sources/Runestone/TextView/Core/TextViewDelegate.swift +++ b/Sources/Runestone/TextView/Core/TextViewDelegate.swift @@ -143,7 +143,7 @@ public extension TextViewDelegate { func textViewDidLoopToFirstHighlightedRange(_ textView: TextView) {} func textView(_ textView: TextView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - false + textView.isEditable } func textView(_ textView: TextView, replaceTextIn highlightedRange: HighlightedRange) {} diff --git a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift new file mode 100644 index 000000000..94e91dbed --- /dev/null +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift @@ -0,0 +1,362 @@ +#if os(macOS) +import AppKit + +@available(macOS 12, *) +final class FindController: NSObject { + static let shared = FindController() + + weak var textView: TextView? { + didSet { + guard findPanelWindow.isVisible else { return } + + // When switching to a different text view, re-run the search + if textView !== oldValue, textView != nil, !searchQuery.isEmpty { + performSearch(query: searchQuery, options: searchOptions) + } else if textView !== oldValue { + // Even if no search is active, update replace button state + updateReplaceButtonState() + } + } + } + + private var findPanel: FindPanel + private var findPanelWindow: NSWindow + private var searchResults: [SearchResult] = [] + private var searchResultIndex = 0 + private var searchQuery = "" + private var searchOptions = FindPanel.SearchOptions() + + private let autosaveName = NSWindow.FrameAutosaveName("findPanel") + + // Background queue for search operations to prevent UI blocking + private let searchQueue = OperationQueue() + private var currentSearchOperation: Operation? + + // Maximum number of highlights to display for performance + private let maxVisibleHighlights = 1000 + + private override init() { + let panel = FindPanel() + findPanel = panel + + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 450, height: 100), + styleMask: [.titled, .miniaturizable, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Find" + window.contentView = panel + window.isReleasedWhenClosed = false + window.level = .floating + + window.collectionBehavior = [.managed, .participatesInCycle, .fullScreenNone] + + window.setFrameAutosaveName(autosaveName) + if !window.setFrameUsingName(autosaveName, force: false) { + window.center() + } + + findPanelWindow = window + + // Private init to enforce singleton + searchQueue.qualityOfService = .userInitiated + searchQueue.maxConcurrentOperationCount = 1 + + super.init() + + panel.delegate = self + window.delegate = self + + updateReplaceButtonState() + } + + // MARK: - Public Methods + + func showFindPanel() { + findPanelWindow.makeKeyAndOrderFront(nil) + findPanel.focusSearchField() + updateReplaceButtonState() + + // Restore previous search query if one exists + if !searchQuery.isEmpty { + findPanel.setSearchString(searchQuery) + } + } + + func focusSearchField() { + findPanel.focusSearchField() + } + + func setSearchString(_ string: String) { + findPanel.setSearchString(string) + } + + func hideFindPanel() { + findPanelWindow.close() + clearSearchHighlights() + } + + func findNext() { + performFind(forward: true) + } + + func findPrevious() { + performFind(forward: false) + } + + /// Refreshes the current search. Call this when the text content changes. + func refreshSearch() { + guard findPanelWindow.isVisible else { return } + + if searchQuery.isEmpty { + // Clear any existing highlights if there's no active search + clearSearchHighlights() + searchResults = [] + searchResultIndex = 0 + findPanel.updateMatchCount(current: 0, total: 0) + } else { + // Re-run the search with current query + performSearch(query: searchQuery, options: searchOptions) + } + } + + // MARK: - Private Methods + + private func performFind(forward: Bool) { + guard !searchResults.isEmpty else { return } + + if forward { + searchResultIndex = (searchResultIndex + 1) % searchResults.count + } else { + searchResultIndex = (searchResultIndex - 1 + searchResults.count) % searchResults.count + } + + updateHighlights() + scrollToCurrentMatch() + updateMatchCountDisplay() + } + + private func performSearch(query: String, options: FindPanel.SearchOptions) { + guard let textView else { return } + + // Store current query and options + searchQuery = query + searchOptions = options + + guard !query.isEmpty else { + // Handle empty query synchronously on main thread + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.clearSearchHighlights() + self.searchResults = [] + self.searchResultIndex = 0 + self.findPanel.updateMatchCount(current: 0, total: 0) + } + return + } + + // Cancel any existing search operation + currentSearchOperation?.cancel() + + // Create a new search operation to run in the background + let operation = BlockOperation() + operation.addExecutionBlock { [weak self, weak operation, weak textView] in + guard let self, let operation, let textView, !operation.isCancelled else { + return + } + + // Prepare search query + let matchMethod: SearchQuery.MatchMethod + if options.isRegularExpression { + matchMethod = .regularExpression + } else { + matchMethod = options.matchMethod + } + + let searchQuery = SearchQuery( + text: query, + matchMethod: matchMethod, + isCaseSensitive: options.isCaseSensitive + ) + + // Perform search on background thread + let searchResults = textView.search(for: searchQuery) + + // Check if cancelled before updating UI + guard !operation.isCancelled else { return } + + // Get selected range for positioning + let selectedRange = textView.selectedRange() + + // Update UI on main thread + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Verify this search is still relevant (query hasn't changed) + guard self.searchQuery == query else { return } + + self.searchResults = searchResults + + if !searchResults.isEmpty { + // Find the index of the first result after the current selection + if let index = searchResults.firstIndex(where: { $0.range.location >= selectedRange.location }) { + self.searchResultIndex = index + } else { + self.searchResultIndex = 0 + } + } else { + self.searchResultIndex = 0 + } + + self.updateHighlights() + if !searchResults.isEmpty { + self.scrollToCurrentMatch() + } + self.updateMatchCountDisplay() + self.updateReplaceButtonState() + } + } + + currentSearchOperation = operation + searchQueue.addOperation(operation) + } + + private func updateReplaceButtonState() { + guard let textView else { + findPanel.setReplaceEnabled(false) + return + } + + // Check if we can replace by asking the delegate + // Use a dummy highlighted range to check permission + let dummyRange = HighlightedRange(range: NSRange(location: 0, length: 0), color: .clear) + let canReplace = textView.editorDelegate?.textView(textView, canReplaceTextIn: dummyRange) ?? true + + findPanel.setReplaceEnabled(canReplace) + } + + private func updateHighlights() { + guard let textView else { return } + + // Clear existing highlights + textView.highlightedRanges.removeAll() + + guard !searchResults.isEmpty else { return } + + // Limit the number of visible highlights for performance + // We still keep all results in searchResults for navigation and count display + let highlightCount = min(searchResults.count, maxVisibleHighlights) + + // Add highlights for matches up to the limit + for index in 0.. Bool { + if event.modifierFlags.contains(.command) && event.charactersIgnoringModifiers == "f" { + focusSearchField() + return true + } + + if event.modifierFlags.contains(.command) && event.charactersIgnoringModifiers == "g" { + if event.modifierFlags.contains(.shift) { + findPrevious(self) + } else { + findNext(self) + } + return true + } + + return super.performKeyEquivalent(with: event) + } + + private func recentsSearchesMenu() -> NSMenu { + let menu = NSMenu(title: "Recent") + + let recentTitleItem = menu.addItem(withTitle: "Recent Searches", action: nil, keyEquivalent: "") + recentTitleItem.tag = Int(NSSearchField.recentsTitleMenuItemTag) + + let placeholder = menu.addItem(withTitle: "", action: nil, keyEquivalent: "") + placeholder.tag = Int(NSSearchField.recentsMenuItemTag) + + menu.addItem(NSMenuItem.separator()) + + let clearItem = menu.addItem(withTitle: "Clear Recent Searches", action: nil, keyEquivalent: "") + clearItem.tag = Int(NSSearchField.clearRecentsMenuItemTag) + + let emptyItem = menu.addItem(withTitle: "No Recent Searches", action: nil, keyEquivalent: "") + emptyItem.tag = Int(NSSearchField.noRecentsMenuItemTag) + + return menu + } + + // MARK: - Public Methods + func updateMatchCount(current: Int, total: Int) { + if total > 0 { + matchCountLabel.stringValue = "\(current) of \(total)" + } else if !searchField.stringValue.isEmpty { + matchCountLabel.stringValue = "No matches" + } else { + matchCountLabel.stringValue = "" + } + } + + func focusSearchField() { + window?.makeFirstResponder(searchField) + searchField.selectText(nil) + } + + func setReplaceEnabled(_ enabled: Bool) { + replaceButton.isEnabled = enabled + replaceAllButton.isEnabled = enabled + } + + func setSearchString(_ string: String) { + searchField.stringValue = string + updateSearchOptions() + delegate?.findPanel(self, didUpdateSearchQuery: string, options: searchOptions) + // Select the text in the search field + searchField.selectText(nil) + } + + // MARK: - Actions + @objc private func searchFieldDidChange(_ sender: NSTextField) { + updateSearchOptions() + delegate?.findPanel(self, didUpdateSearchQuery: sender.stringValue, options: searchOptions) + } + + @objc private func navigationAction(_ sender: NSSegmentedControl) { + switch sender.selectedSegment { + case 0: + findPrevious(self) + case 1: + findNext(self) + default: + break + } + } + + @objc private func findPrevious(_ sender: Any) { + delegate?.findPanel(self, didRequestFindNext: false) + } + + @objc private func findNext(_ sender: Any) { + delegate?.findPanel(self, didRequestFindNext: true) + } + + @objc private func replace(_ sender: Any) { + // Get current selection/highlighted range + // This will be handled by the delegate + let replacementText = replaceField.stringValue + // The delegate needs to determine which range to replace + // For now, we'll pass NSRange(location: NSNotFound, length: 0) as a placeholder + // The delegate should replace the currently selected/highlighted match + delegate?.findPanel(self, didRequestReplace: NSRange(location: NSNotFound, length: 0), with: replacementText) + } + + @objc private func replaceAll(_ sender: Any) { + let query = searchField.stringValue + let replacementText = replaceField.stringValue + updateSearchOptions() + delegate?.findPanel(self, didRequestReplaceAll: query, with: replacementText, options: searchOptions) + } + + @objc private func done(_ sender: Any) { + window?.close() + } + + @objc private func optionDidChange(_ sender: Any) { + updateSearchOptions() + delegate?.findPanel(self, didUpdateSearchQuery: searchField.stringValue, options: searchOptions) + } + + private func updateSearchOptions() { + // Ignore Case checkbox - inverted from isCaseSensitive + searchOptions.isCaseSensitive = ignoreCaseCheckbox.state == .off + + // Search mode popup determines both match method and if it's a regex + switch searchModePopup.indexOfSelectedItem { + case 0: // Contains + searchOptions.matchMethod = .contains + searchOptions.isRegularExpression = false + case 1: // Starts With + searchOptions.matchMethod = .startsWith + searchOptions.isRegularExpression = false + case 2: // Ends With + searchOptions.matchMethod = .endsWith + searchOptions.isRegularExpression = false + case 3: // Full Word + searchOptions.matchMethod = .fullWord + searchOptions.isRegularExpression = false + case 4: // Regular Expression + searchOptions.matchMethod = .regularExpression + searchOptions.isRegularExpression = true + default: + searchOptions.matchMethod = .contains + searchOptions.isRegularExpression = false + } + } +} + +// MARK: - NSSearchFieldDelegate +@available(macOS 12, *) +extension FindPanel: NSSearchFieldDelegate { + func controlTextDidChange(_ obj: Notification) { + if obj.object as? NSTextField === searchField { + searchFieldDidChange(searchField) + } + } + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if control === searchField { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + // Enter key pressed - find next + findNext(control) + return true + } else if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + // Escape key pressed - close panel + done(control) + return true + } + } + return false + } +} +#endif From 2ce6162856f30797d90bed45fdf60dd9cb0267c8 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sun, 16 Nov 2025 23:01:43 +0200 Subject: [PATCH 208/232] Remove highlights from previously focused text view when focus moves to another one. --- .../SearchAndReplace/Mac/FindController.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift index 94e91dbed..aaf0588d2 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift @@ -7,14 +7,16 @@ final class FindController: NSObject { weak var textView: TextView? { didSet { - guard findPanelWindow.isVisible else { return } + // Even if no search is active, update replace button state + updateReplaceButtonState() + + guard textView != oldValue else { return } + + oldValue?.highlightedRanges.removeAll() // When switching to a different text view, re-run the search - if textView !== oldValue, textView != nil, !searchQuery.isEmpty { + if findPanelWindow.isVisible, !searchQuery.isEmpty { performSearch(query: searchQuery, options: searchOptions) - } else if textView !== oldValue { - // Even if no search is active, update replace button state - updateReplaceButtonState() } } } From e1b68c4fd8ef9ed64266be499444db1718556465 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 17 Nov 2025 00:31:04 +0200 Subject: [PATCH 209/232] Adjust match count label size. --- .../Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift index af3fc6527..332d1df53 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift @@ -93,8 +93,8 @@ final class FindPanel: NSView { matchCountLabel.isEditable = false matchCountLabel.isBordered = false matchCountLabel.drawsBackground = false - matchCountLabel.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize) - matchCountLabel.textColor = NSColor.secondaryLabelColor + matchCountLabel.font = .monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + matchCountLabel.textColor = .secondaryLabelColor matchCountLabel.alignment = .right matchCountLabel.setContentHuggingPriority(.init(rawValue: 1), for: .horizontal) From 29e1ce283f9a7d3d30cbbdcd22bc860cf4b04266 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 17 Nov 2025 09:56:03 +0200 Subject: [PATCH 210/232] Remove unnecessary macOS 12 availability checks. Reduce max visible search highlights to 200. Check current search operation state in main queue. Refresh find panel state when text changes by editing and when setState is used to update text. --- .../TextView/Appearance/DefaultTheme.swift | 1 - .../Runestone/TextView/Appearance/Theme.swift | 2 -- .../TextView/Core/Mac/TextView_Mac+Find.swift | 9 -------- .../TextView/Core/Mac/TextView_Mac.swift | 20 ++++++++-------- .../SearchAndReplace/Mac/FindController.swift | 23 ++++++++----------- .../SearchAndReplace/Mac/FindPanel.swift | 3 --- 6 files changed, 19 insertions(+), 39 deletions(-) diff --git a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift index bf04955f8..8f0560d76 100644 --- a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift +++ b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift @@ -89,7 +89,6 @@ public final class DefaultTheme: Runestone.Theme { } } #elseif os(macOS) - @available(macOS 12, *) public func highlightedRange(forFoundTextRange foundTextRange: NSRange, isSelected: Bool) -> HighlightedRange? { if isSelected { let color = MultiPlatformColor(themeColorNamed: "search_match_highlighted") diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index aebce30b8..28d33dbc1 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -77,7 +77,6 @@ public protocol Theme: AnyObject { /// - foundTextRange: The text range matching a search query. /// - isSelected: Whether this is the currently selected match (true) or just a found match (false). /// - Returns: The object used for highlighting the provided text range, or `nil` if the range should not be highlighted. - @available(macOS 12, *) func highlightedRange(forFoundTextRange foundTextRange: NSRange, isSelected: Bool) -> HighlightedRange? #endif } @@ -130,7 +129,6 @@ public extension Theme { } } #elseif os(macOS) - @available(macOS 12, *) func highlightedRange(forFoundTextRange foundTextRange: NSRange, isSelected: Bool) -> HighlightedRange? { if isSelected { return HighlightedRange(range: foundTextRange, color: .systemYellow) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift index 9d1297155..46df48720 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift @@ -5,11 +5,6 @@ import AppKit extension TextView { /// Bridge NSTextFinder actions to custom find implementation @objc override public func performTextFinderAction(_ sender: Any?) { - guard #available(macOS 12, *) else { - super.performTextFinderAction(sender) - return - } - guard let menuItem = sender as? NSMenuItem else { super.performTextFinderAction(sender) return @@ -30,10 +25,8 @@ extension TextView { case .previousMatch: findPrevious(sender) case .replace, .replaceAndFind: - // Show find panel - replace functionality is available there showFindPanel(sender) case .replaceAll: - // Show find panel - replace all functionality is available there showFindPanel(sender) case .hideFindInterface: hideFindPanel(sender) @@ -45,8 +38,6 @@ extension TextView { } } -// MARK: - Find Panel (macOS 12+) -@available(macOS 12, *) extension TextView { private var findController: FindController { FindController.shared diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 089ad9263..3fed33af6 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -500,9 +500,7 @@ open class TextView: NSView, NSMenuItemValidation { editorDelegate?.textViewDidBeginEditing(self) } // Notify find controller that this text view is now focused - if #available(macOS 12, *) { - notifyFindControllerDidBecomeFocused() - } + notifyFindControllerDidBecomeFocused() return true } @@ -572,6 +570,7 @@ open class TextView: NSView, NSMenuItemValidation { /// - Parameter addUndoAction: Whether the state change can be undone. Defaults to false. public func setState(_ state: TextViewState, addUndoAction: Bool = false) { textViewController.setState(state, addUndoAction: addUndoAction) + refreshFindPanelSearch() // Layout to ensure the selection erctangles and caret as correctly placed. setNeedsLayout() layoutIfNeeded() @@ -702,9 +701,9 @@ open class TextView: NSView, NSMenuItemValidation { return isEditable && undoManager?.canUndo ?? false } else if menuItem.action == #selector(redo(_:)) { return isEditable && undoManager?.canRedo ?? false - } else if #available(macOS 12, *), menuItem.action == #selector(showFindPanel(_:)) { + } else if menuItem.action == #selector(showFindPanel(_:)) { return true - } else if #available(macOS 12, *), menuItem.action == #selector(findNext(_:)) || menuItem.action == #selector(findPrevious(_:)) { + } else if menuItem.action == #selector(findNext(_:)) || menuItem.action == #selector(findPrevious(_:)) { // These are enabled if there are search results return true } else if menuItem.action == #selector(performTextFinderAction(_:)) { @@ -847,12 +846,10 @@ private extension TextView { menu?.addItem(withTitle: L10n.Menu.ItemTitle.paste, action: #selector(paste(_:)), keyEquivalent: "v") menu?.addItem(.separator()) menu?.addItem(withTitle: L10n.Menu.ItemTitle.selectAll, action: #selector(selectAll(_:)), keyEquivalent: "a") - if #available(macOS 12, *) { - menu?.addItem(.separator()) - menu?.addItem(withTitle: "Find...", action: #selector(showFindPanel(_:)), keyEquivalent: "f") - menu?.addItem(withTitle: "Find Next", action: #selector(findNext(_:)), keyEquivalent: "g") - menu?.addItem(withTitle: "Find Previous", action: #selector(findPrevious(_:)), keyEquivalent: "G") - } + menu?.addItem(.separator()) + menu?.addItem(withTitle: "Find...", action: #selector(showFindPanel(_:)), keyEquivalent: "f") + menu?.addItem(withTitle: "Find Next", action: #selector(findNext(_:)), keyEquivalent: "g") + menu?.addItem(withTitle: "Find Previous", action: #selector(findPrevious(_:)), keyEquivalent: "G") } } @@ -861,6 +858,7 @@ extension TextView: TextViewControllerDelegate { func textViewControllerDidChangeText(_ textViewController: TextViewController) { caretView.delayBlinkIfNeeded() updateCaretFrame() + refreshFindPanelSearch() editorDelegate?.textViewDidChange(self) } diff --git a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift index aaf0588d2..78e781c69 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift @@ -1,7 +1,6 @@ #if os(macOS) import AppKit -@available(macOS 12, *) final class FindController: NSObject { static let shared = FindController() @@ -32,10 +31,10 @@ final class FindController: NSObject { // Background queue for search operations to prevent UI blocking private let searchQueue = OperationQueue() - private var currentSearchOperation: Operation? + private var searchOperation: Operation? // Maximum number of highlights to display for performance - private let maxVisibleHighlights = 1000 + private let maxVisibleHighlights = 200 private override init() { let panel = FindPanel() @@ -159,7 +158,7 @@ final class FindController: NSObject { } // Cancel any existing search operation - currentSearchOperation?.cancel() + searchOperation?.cancel() // Create a new search operation to run in the background let operation = BlockOperation() @@ -185,21 +184,21 @@ final class FindController: NSObject { // Perform search on background thread let searchResults = textView.search(for: searchQuery) - // Check if cancelled before updating UI - guard !operation.isCancelled else { return } - - // Get selected range for positioning - let selectedRange = textView.selectedRange() - // Update UI on main thread DispatchQueue.main.async { [weak self] in guard let self = self else { return } + // Check if cancelled before updating UI + guard !operation.isCancelled else { return } + // Verify this search is still relevant (query hasn't changed) guard self.searchQuery == query else { return } self.searchResults = searchResults + // Get selected range for positioning + let selectedRange = textView.selectedRange() + if !searchResults.isEmpty { // Find the index of the first result after the current selection if let index = searchResults.firstIndex(where: { $0.range.location >= selectedRange.location }) { @@ -220,7 +219,7 @@ final class FindController: NSObject { } } - currentSearchOperation = operation + searchOperation = operation searchQueue.addOperation(operation) } @@ -278,7 +277,6 @@ final class FindController: NSObject { } // MARK: - FindPanelDelegate -@available(macOS 12, *) extension FindController: FindPanelDelegate { func findPanel(_ panel: FindPanel, didUpdateSearchQuery query: String, options: FindPanel.SearchOptions) { performSearch(query: query, options: options) @@ -354,7 +352,6 @@ extension FindController: FindPanelDelegate { } // MARK: - NSWindowDelegate -@available(macOS 12, *) extension FindController: NSWindowDelegate { func windowWillClose(_ notification: Notification) { clearSearchHighlights() diff --git a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift index 332d1df53..bf5533aae 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift @@ -2,7 +2,6 @@ import AppKit /// Delegate protocol for find panel interactions -@available(macOS 12, *) protocol FindPanelDelegate: AnyObject { func findPanel(_ panel: FindPanel, didUpdateSearchQuery query: String, options: FindPanel.SearchOptions) func findPanel(_ panel: FindPanel, didRequestFindNext: Bool) @@ -11,7 +10,6 @@ protocol FindPanelDelegate: AnyObject { } /// A custom find panel for searching and replacing text in a TextView -@available(macOS 12, *) final class FindPanel: NSView { struct SearchOptions { var isCaseSensitive: Bool = false @@ -317,7 +315,6 @@ final class FindPanel: NSView { } // MARK: - NSSearchFieldDelegate -@available(macOS 12, *) extension FindPanel: NSSearchFieldDelegate { func controlTextDidChange(_ obj: Notification) { if obj.object as? NSTextField === searchField { From 2c4d4bf85c36bb644ef03d20303ead038f31d9d1 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 17 Nov 2025 13:27:05 +0200 Subject: [PATCH 211/232] Show find panel when finding next or previous match so that closing the panel can remove highlights from text view. --- Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift index 46df48720..ad8a1f398 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift @@ -21,8 +21,10 @@ extension TextView { case .showReplaceInterface: showFindPanel(sender) case .nextMatch: + showFindPanel(sender) findNext(sender) case .previousMatch: + showFindPanel(sender) findPrevious(sender) case .replace, .replaceAndFind: showFindPanel(sender) From 048ec931880640b68a312636cb5ebf619facfc0e Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 17 Nov 2025 16:01:16 +0200 Subject: [PATCH 212/232] Add padding around rect to scroll to correct offset. --- .../TextViewController+Scrolling.swift | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift index 8c9f4f7c6..a2377ae8b 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift @@ -41,6 +41,7 @@ private extension TextViewController { /// - Parameter rect: The rectangle to reveal. /// - Returns: The content offset to scroll to. private func contentOffsetForScrollingToVisibleRect(_ rect: CGRect) -> CGPoint { + let scrollPadding: CGFloat = 40 // Create the viewport: a rectangle containing the content that is visible to the user. var viewport = CGRect(origin: scrollView.contentOffset, size: textView.frame.size) viewport.origin.y += scrollView.adjustedContentInset.top + textContainerInset.top @@ -56,21 +57,22 @@ private extension TextViewController { + textContainerInset.bottom // Construct the best possible content offset. var newContentOffset = scrollView.contentOffset - if rect.minX < viewport.minX { - newContentOffset.x -= viewport.minX - rect.minX - } else if rect.maxX > viewport.maxX && rect.width <= viewport.width { + if rect.minX < viewport.minX + scrollPadding { + newContentOffset.x -= viewport.minX - rect.minX + scrollPadding + } else if rect.maxX > viewport.maxX - scrollPadding && rect.width <= viewport.width { // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. - newContentOffset.x += rect.maxX - viewport.maxX + newContentOffset.x += rect.maxX - viewport.maxX + scrollPadding } else if rect.maxX > viewport.maxX { newContentOffset.x += rect.minX } - if rect.minY < viewport.minY { - newContentOffset.y -= viewport.minY - rect.minY - } else if rect.maxY > viewport.maxY && rect.height <= viewport.height { + if rect.minY < viewport.minY + scrollPadding { + newContentOffset.y -= viewport.minY - rect.minY + scrollPadding + } else if rect.maxY > viewport.maxY - scrollPadding && rect.height <= viewport.height { // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. - newContentOffset.y += rect.maxY - viewport.maxY - } else if rect.maxY > viewport.maxY { - newContentOffset.y += rect.minY + newContentOffset.y += rect.maxY - viewport.maxY + scrollPadding + } else if rect.maxY > viewport.maxY - scrollPadding { + // Bottom of rect extends beyond viewport - scroll down just enough to reveal it + newContentOffset.y += rect.maxY - viewport.maxY + scrollPadding } let cappedXOffset = min(max(newContentOffset.x, scrollView.minimumContentOffset.x), scrollView.maximumContentOffset.x) let cappedYOffset = min(max(newContentOffset.y, scrollView.minimumContentOffset.y), scrollView.maximumContentOffset.y) From 2b4a0d215ed7a49eb01b182294a6a91b47938d4f Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 17 Nov 2025 21:12:27 +0200 Subject: [PATCH 213/232] Fix start x coordinate for highlighted line fragments. Fix repeated insertion to highlighted ranges which has a side effect. --- .../LineController/LineFragmentRenderer.swift | 4 ++-- .../SearchAndReplace/Mac/FindController.swift | 14 +++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift index 96ec490a6..d8ff8afa6 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift @@ -61,13 +61,13 @@ private extension LineFragmentRenderer { context.fillPath() // Draw non-rounded edges if needed. if !highlightedRange.containsStart { - let startRect = CGRect(x: 0, y: 0, width: cornerRadius, height: rect.height) + let startRect = CGRect(x: startX, y: 0, width: cornerRadius, height: rect.height) let startPath = CGPath(rect: startRect, transform: nil) context.addPath(startPath) context.fillPath() } if !highlightedRange.containsEnd { - let endRect = CGRect(x: 0, y: 0, width: rect.width - cornerRadius, height: rect.height) + let endRect = CGRect(x: startX, y: 0, width: rect.width - cornerRadius, height: rect.height) let endPath = CGPath(rect: endRect, transform: nil) context.addPath(endPath) context.fillPath() diff --git a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift index 78e781c69..93f876f48 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift @@ -240,23 +240,27 @@ final class FindController: NSObject { private func updateHighlights() { guard let textView else { return } - // Clear existing highlights - textView.highlightedRanges.removeAll() + guard !searchResults.isEmpty else { + textView.highlightedRanges.removeAll() + return + } - guard !searchResults.isEmpty else { return } + var highlightedRanges: [HighlightedRange] = [] // Limit the number of visible highlights for performance // We still keep all results in searchResults for navigation and count display let highlightCount = min(searchResults.count, maxVisibleHighlights) // Add highlights for matches up to the limit - for index in 0.. Date: Mon, 17 Nov 2025 21:32:19 +0200 Subject: [PATCH 214/232] Fix corner radius in highlighted range. Fix drawing parts of highlighted range. Increase maximum count of visible highlights to 1000. --- Sources/Runestone/TextView/Appearance/Theme.swift | 4 ++-- .../TextView/LineController/LineFragmentRenderer.swift | 2 +- .../TextView/SearchAndReplace/Mac/FindController.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index 28d33dbc1..14ebfe1c4 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -131,9 +131,9 @@ public extension Theme { #elseif os(macOS) func highlightedRange(forFoundTextRange foundTextRange: NSRange, isSelected: Bool) -> HighlightedRange? { if isSelected { - return HighlightedRange(range: foundTextRange, color: .systemYellow) + return HighlightedRange(range: foundTextRange, color: .systemYellow, cornerRadius: 2) } else { - return HighlightedRange(range: foundTextRange, color: .systemYellow.withAlphaComponent(0.2)) + return HighlightedRange(range: foundTextRange, color: .systemYellow.withAlphaComponent(0.2), cornerRadius: 2) } } #endif diff --git a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift index d8ff8afa6..55bcb6ca0 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift @@ -67,7 +67,7 @@ private extension LineFragmentRenderer { context.fillPath() } if !highlightedRange.containsEnd { - let endRect = CGRect(x: startX, y: 0, width: rect.width - cornerRadius, height: rect.height) + let endRect = CGRect(x: endX - cornerRadius, y: 0, width: cornerRadius, height: rect.height) let endPath = CGPath(rect: endRect, transform: nil) context.addPath(endPath) context.fillPath() diff --git a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift index 93f876f48..6ae167f6a 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift @@ -34,7 +34,7 @@ final class FindController: NSObject { private var searchOperation: Operation? // Maximum number of highlights to display for performance - private let maxVisibleHighlights = 200 + private let maxVisibleHighlights = 1000 private override init() { let panel = FindPanel() From 1d8005c43b051e43d4aa2fa4e1dad7c04357b5e1 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Tue, 18 Nov 2025 09:43:24 +0200 Subject: [PATCH 215/232] Increase scroll padding. --- .../Core/TextViewController/TextViewController+Scrolling.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift index a2377ae8b..e59b1381b 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift @@ -41,7 +41,7 @@ private extension TextViewController { /// - Parameter rect: The rectangle to reveal. /// - Returns: The content offset to scroll to. private func contentOffsetForScrollingToVisibleRect(_ rect: CGRect) -> CGPoint { - let scrollPadding: CGFloat = 40 + let scrollPadding: CGFloat = 60 // Create the viewport: a rectangle containing the content that is visible to the user. var viewport = CGRect(origin: scrollView.contentOffset, size: textView.frame.size) viewport.origin.y += scrollView.adjustedContentInset.top + textContainerInset.top From 09b77ad51834753436fe9f99ac7064ba5bdde619 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Tue, 18 Nov 2025 09:56:37 +0200 Subject: [PATCH 216/232] Update autosave name for findpanel search field. Save new search texts to recent searches. --- .../TextView/SearchAndReplace/Mac/FindPanel.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift index bf5533aae..8d3931a4a 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift @@ -25,7 +25,7 @@ final class FindPanel: NSView { private let replaceAllButton = NSButton() private let matchCountLabel = NSTextField(labelWithString: "") - private let searchAutosaveName = "searches" + private let searchAutosaveName = "findPanel" private let navigationControl = NSSegmentedControl() @@ -321,6 +321,15 @@ extension FindPanel: NSSearchFieldDelegate { searchFieldDidChange(searchField) } } + + func controlTextDidEndEditing(_ obj: Notification) { + if obj.object as? NSTextField === searchField { + let searchText = searchField.stringValue + if !searchText.isEmpty && searchField.recentSearches.first != searchText { + searchField.recentSearches.insert(searchText, at: 0) + } + } + } func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if control === searchField { From 6b6e22cd451edf1656777953290e7d09482b11c8 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Tue, 18 Nov 2025 12:24:40 +0200 Subject: [PATCH 217/232] Fix case of ignore case checkbox title in FindPanel. --- Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift index 8d3931a4a..562df2b59 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift @@ -29,7 +29,7 @@ final class FindPanel: NSView { private let navigationControl = NSSegmentedControl() - private let ignoreCaseCheckbox = NSButton(checkboxWithTitle: "Ignore Case", target: nil, action: nil) + private let ignoreCaseCheckbox = NSButton(checkboxWithTitle: "Ignore case", target: nil, action: nil) private let searchModeLabel = NSTextField() private let searchModePopup = NSPopUpButton() From e3561976d828dbcc36e4fe04f9f8de59c92b4c50 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 22 Nov 2025 15:30:14 +0200 Subject: [PATCH 218/232] Remove unused hairlineLength definition. --- Sources/Runestone/Library/HairlineLength.swift | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 Sources/Runestone/Library/HairlineLength.swift diff --git a/Sources/Runestone/Library/HairlineLength.swift b/Sources/Runestone/Library/HairlineLength.swift deleted file mode 100644 index e1f42580b..000000000 --- a/Sources/Runestone/Library/HairlineLength.swift +++ /dev/null @@ -1,7 +0,0 @@ -import UIKit - -#if compiler(<5.9) || !os(visionOS) -let hairlineLength = 1 / UIScreen.main.scale -#else -let hairlineLength: CGFloat = 1 -#endif From 21b99a41994dd2ea35fb5befb756893f1e924ffb Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 22 Nov 2025 15:30:46 +0200 Subject: [PATCH 219/232] Add back StringSyntaxHighlighter. --- Sources/Runestone/StringSyntaxHighlighter.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/Runestone/StringSyntaxHighlighter.swift b/Sources/Runestone/StringSyntaxHighlighter.swift index 000122d47..9fb4dc327 100644 --- a/Sources/Runestone/StringSyntaxHighlighter.swift +++ b/Sources/Runestone/StringSyntaxHighlighter.swift @@ -1,3 +1,4 @@ +#if os(iOS) import UIKit /// Syntax highlights a string. @@ -103,3 +104,4 @@ private extension StringSyntaxHighlighter { return mutableParagraphStyle } } +#endif From 82c129db460c45fd153d6fb68bf5b68acaa6b880 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 22 Nov 2025 15:31:17 +0200 Subject: [PATCH 220/232] Add missing #endif statements. --- Sources/Runestone/TextView/Core/iOS/EditMenuController.swift | 1 + .../TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift b/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift index ccebca343..c16ae2654 100644 --- a/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift +++ b/Sources/Runestone/TextView/Core/iOS/EditMenuController.swift @@ -103,3 +103,4 @@ extension EditMenuController: UIEditMenuInteractionDelegate { } } } +#endif diff --git a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift index 9450bac49..b14205fb4 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift @@ -197,3 +197,4 @@ private extension SearchQuery.MatchMethod { } } } +#endif From 15b5f71824b89bfaf0d66f1e96ed464fba09a857 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 22 Nov 2025 15:31:56 +0200 Subject: [PATCH 221/232] Update LineSyntaxHighlighter with new properties. --- .../LineController/LineSyntaxHighlighter.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift b/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift index 8f0319855..9f2d5ecf5 100644 --- a/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift +++ b/Sources/Runestone/TextView/LineController/LineSyntaxHighlighter.swift @@ -23,8 +23,24 @@ 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() + } +} From 4299b2744c88cb10a0565581e077426329de2f49 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 22 Nov 2025 15:32:17 +0200 Subject: [PATCH 222/232] Make DefaultStringAttributes multiplatform. --- .../Runestone/Library/DefaultStringAttributes.swift | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Sources/Runestone/Library/DefaultStringAttributes.swift b/Sources/Runestone/Library/DefaultStringAttributes.swift index aab529d42..864703cda 100644 --- a/Sources/Runestone/Library/DefaultStringAttributes.swift +++ b/Sources/Runestone/Library/DefaultStringAttributes.swift @@ -1,9 +1,13 @@ -import Foundation +#if os(macOS) +import AppKit +#endif +#if os(iOS) import UIKit +#endif struct DefaultStringAttributes { - let textColor: UIColor - let font: UIFont + let textColor: MultiPlatformColor + let font: MultiPlatformFont let kern: CGFloat let tabWidth: CGFloat From 16906323d2e44e493eeeb801b9d514b5bdd112ea Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 22 Nov 2025 15:32:32 +0200 Subject: [PATCH 223/232] Make TabWidthMeasurer multiplatform. --- Sources/Runestone/Library/TabWidthMeasurer.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/Library/TabWidthMeasurer.swift b/Sources/Runestone/Library/TabWidthMeasurer.swift index 94bc5b69a..c605b1c52 100644 --- a/Sources/Runestone/Library/TabWidthMeasurer.swift +++ b/Sources/Runestone/Library/TabWidthMeasurer.swift @@ -1,10 +1,20 @@ +#if os(macOS) +import AppKit +#endif +#if os(iOS) import UIKit +#endif enum TabWidthMeasurer { - static func tabWidth(tabLength: Int, font: UIFont) -> CGFloat { + static func tabWidth(tabLength: Int, font: MultiPlatformFont) -> CGFloat { let str = String(repeating: " ", count: tabLength) let maxSize = CGSize(width: CGFloat.greatestFiniteMagnitude, height: .greatestFiniteMagnitude) + #if os(macOS) + let options: NSString.DrawingOptions = [.usesFontLeading, .usesLineFragmentOrigin] + #endif + #if os(iOS) let options: NSStringDrawingOptions = [.usesFontLeading, .usesLineFragmentOrigin] + #endif let attributes: [NSAttributedString.Key: Any] = [.font: font] let bounds = str.boundingRect(with: maxSize, options: options, attributes: attributes, context: nil) return round(bounds.size.width) From 1ac21373924307c69c7cef8e6913dd5d5bcf174c Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 22 Nov 2025 15:37:26 +0200 Subject: [PATCH 224/232] Multiple fixes after merging mac branch to main Allow selection in non-editable text views on iOS. Update TextInputStringTokenizer. Add back text selection workarounds. Add character method to StringView. Remove lengthOfInitallyLongestLine from text views. Remove unnecessary files. --- .../Library/UITextInput+Helpers.swift | 23 - ...tSelectionDisplayInteraction+Helpers.swift | 4 +- .../TextView/Core/Mac/TextView_Mac.swift | 6 - .../Runestone/TextView/Core/StringView.swift | 8 + .../Core/TextInputStringTokenizer.swift | 273 --- .../TextView/Core/TextInputView.swift | 1676 ----------------- .../Runestone/TextView/Core/TextView.swift | 1507 --------------- .../Core/iOS/TextInputStringTokenizer.swift | 305 ++- .../Core/iOS/TextView_iOS+UITextInput.swift | 22 + .../TextView/Core/iOS/TextView_iOS.swift | 23 +- 10 files changed, 274 insertions(+), 3573 deletions(-) delete mode 100644 Sources/Runestone/Library/UITextInput+Helpers.swift rename Sources/Runestone/Library/{ => iOS}/UITextSelectionDisplayInteraction+Helpers.swift (89%) delete mode 100644 Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift delete mode 100644 Sources/Runestone/TextView/Core/TextInputView.swift delete mode 100644 Sources/Runestone/TextView/Core/TextView.swift diff --git a/Sources/Runestone/Library/UITextInput+Helpers.swift b/Sources/Runestone/Library/UITextInput+Helpers.swift deleted file mode 100644 index 60b911840..000000000 --- a/Sources/Runestone/Library/UITextInput+Helpers.swift +++ /dev/null @@ -1,23 +0,0 @@ -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: String = "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/iOS/UITextSelectionDisplayInteraction+Helpers.swift similarity index 89% rename from Sources/Runestone/Library/UITextSelectionDisplayInteraction+Helpers.swift rename to Sources/Runestone/Library/iOS/UITextSelectionDisplayInteraction+Helpers.swift index 23b19f8bf..ec999a46e 100644 --- a/Sources/Runestone/Library/UITextSelectionDisplayInteraction+Helpers.swift +++ b/Sources/Runestone/Library/iOS/UITextSelectionDisplayInteraction+Helpers.swift @@ -1,12 +1,10 @@ +#if os(iOS) 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/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 3fed33af6..32c86c057 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -356,12 +356,6 @@ open class TextView: NSView, NSMenuItemValidation { textViewController.horizontalOverscrollFactor = newValue } } - /// 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? { - textViewController.lengthOfInitallyLongestLine - } /// Ranges in the text to be highlighted. The color defined by the background will be drawen behind the text. public var highlightedRanges: [HighlightedRange] { get { diff --git a/Sources/Runestone/TextView/Core/StringView.swift b/Sources/Runestone/TextView/Core/StringView.swift index a1ee3c7c6..d00356e5d 100644 --- a/Sources/Runestone/TextView/Core/StringView.swift +++ b/Sources/Runestone/TextView/Core/StringView.swift @@ -55,6 +55,14 @@ final class StringView { } } + func character(at location: Int) -> Character? { + if location >= 0 && location < string.length, let scalar = Unicode.Scalar(internalString.character(at: location)) { + return Character(scalar) + } else { + return nil + } + } + func replaceText(in range: NSRange, with string: String) { internalString.replaceCharacters(in: range, with: string) invalidate() diff --git a/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift deleted file mode 100644 index 12f45fab2..000000000 --- a/Sources/Runestone/TextView/Core/TextInputStringTokenizer.swift +++ /dev/null @@ -1,273 +0,0 @@ -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] { - [Symbol.Character.lineFeed, Symbol.Character.carriageReturn, Symbol.Character.carriageReturnLineFeed] - } - - init(textInput: UIResponder & UITextInput, stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { - self.lineManager = lineManager - self.stringView = stringView - self.lineControllerStorage = lineControllerStorage - super.init(textInput: textInput) - } - - override func isPosition(_ position: UITextPosition, atBoundary granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool { - if granularity == .line { - return isPosition(position, atLineBoundaryInDirection: direction) - } else if granularity == .paragraph { - 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 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 { - 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) - } - } -} - -// MARK: - Lines -private extension TextInputStringTokenizer { - private func isPosition(_ position: UITextPosition, atLineBoundaryInDirection direction: UITextDirection) -> Bool { - guard let indexedPosition = position as? IndexedPosition else { - return false - } - let location = indexedPosition.index - guard let line = lineManager.line(containingCharacterAt: location) else { - return false - } - let lineLocation = line.location - let lineLocalLocation = location - lineLocation - let lineController = lineControllerStorage.getOrCreateLineController(for: line) - guard lineLocalLocation >= 0 && lineLocalLocation <= line.data.totalLength else { - return false - } - guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { - return false - } - if direction.isForward { - let isLastLineFragment = lineFragmentNode.index == lineController.numberOfLineFragments - 1 - if isLastLineFragment { - return location == lineLocation + lineFragmentNode.location + lineFragmentNode.value - line.data.delimiterLength - } else { - return location == lineLocation + lineFragmentNode.location + lineFragmentNode.value - } - } else { - return location == lineLocation + lineFragmentNode.location - } - } - - 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 - } - let lineController = lineControllerStorage.getOrCreateLineController(for: line) - let lineLocation = line.location - let lineLocalLocation = location - lineLocation - guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { - return nil - } - if direction.isForward { - if location == stringView.string.length { - return position - } else { - let lineFragmentRangeUpperBound = lineFragmentNode.location + lineFragmentNode.value - let preferredLocation = lineLocation + lineFragmentRangeUpperBound - 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: lineFragmentRangeUpperBound) - return IndexedPosition(index: lineLocation + lineFragmentRangeUpperBound - lastCharacterRange.length) - } - } - } else if location == 0 { - return position - } else { - return IndexedPosition(index: lineLocation + lineFragmentNode.location) - } - } -} - -// 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. - false - } - - private func position(from position: UITextPosition, toParagraphBoundaryInDirection direction: UITextDirection) -> UITextPosition? { - guard let indexedPosition = position as? IndexedPosition else { - return nil - } - 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 { - 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) - } - } - } -} - -// MARK: - Words -private extension TextInputStringTokenizer { - private func isPosition(_ position: UITextPosition, atWordBoundaryInDirection direction: UITextDirection) -> Bool { - guard let indexedPosition = position as? IndexedPosition else { - return false - } - 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 - } - } - } - - // swiftlint:disable:next cyclomatic_complexity - private func position(from position: UITextPosition, toWordBoundaryInDirection direction: UITextDirection) -> UITextPosition? { - guard let indexedPosition = position as? IndexedPosition else { - return nil - } - didCallPositionFromPositionToWordBoundary = true - 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 { - rawValue == UITextStorageDirection.forward.rawValue - || rawValue == UITextLayoutDirection.right.rawValue - || rawValue == UITextLayoutDirection.down.rawValue - } -} - -private extension CharacterSet { - func contains(_ character: Character) -> Bool { - character.unicodeScalars.allSatisfy(contains(_:)) - } -} diff --git a/Sources/Runestone/TextView/Core/TextInputView.swift b/Sources/Runestone/TextView/Core/TextInputView.swift deleted file mode 100644 index 97e03154f..000000000 --- a/Sources/Runestone/TextView/Core/TextInputView.swift +++ /dev/null @@ -1,1676 +0,0 @@ -// swiftlint:disable file_length -import Combine -import UIKit - -protocol TextInputViewDelegate: AnyObject { - func textInputViewWillBeginEditing(_ view: TextInputView) - func textInputViewDidBeginEditing(_ view: TextInputView) - func textInputViewDidEndEditing(_ view: TextInputView) - func textInputViewDidCancelBeginEditing(_ view: TextInputView) - func textInputViewDidChange(_ view: TextInputView) - func textInputViewDidChangeSelection(_ view: TextInputView) - func textInputView(_ view: TextInputView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool - func textInputViewDidInvalidateContentSize(_ view: TextInputView) - func textInputView(_ view: TextInputView, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) - func textInputViewDidChangeGutterWidth(_ view: TextInputView) - func textInputViewDidBeginFloatingCursor(_ view: TextInputView) - func textInputViewDidEndFloatingCursor(_ view: TextInputView) - func textInputViewDidUpdateMarkedRange(_ view: TextInputView) - func textInputView(_ view: TextInputView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool - func textInputView(_ view: TextInputView, replaceTextIn highlightedRange: HighlightedRange) -} - -// swiftlint:disable:next type_body_length -final class TextInputView: UIView, UITextInput { - // MARK: - UITextInput - var selectedTextRange: UITextRange? { - get { - if let range = _selectedRange { - return IndexedRange(range) - } else { - return nil - } - } - set { - // We should not use this setter. It's intended for UIKit to use. It'll invoke the setter in various scenarios, for example when navigating the text using the keyboard. - // On the iOS 16 beta, UIKit may pass an NSRange with a negatives length (e.g. {4, -2}) when double tapping to select text. This will cause a crash when UIKit later attempts to use the selected range with NSString's -substringWithRange:. This can be tested with a string containing the following three lines: - // A - // - // A - // Placing the character on the second line, which is empty, and double tapping several times on the empty line to select text will cause the editor to crash. To work around this we take the non-negative value of the selected range. Last tested on August 30th, 2022. - let newRange = (newValue as? IndexedRange)?.range.nonNegativeLength - if newRange != _selectedRange { - notifyDelegateAboutSelectionChangeInLayoutSubviews = 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 - } - // 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) - } - _selectedRange = newRange - if shouldNotifyInputDelegate { - inputDelegate?.selectionDidChange(self) - } - } - } - } - private(set) var markedTextRange: UITextRange? { - get { - if let markedRange = markedRange { - return IndexedRange(markedRange) - } else { - return nil - } - } - set { - markedRange = (newValue as? IndexedRange)?.range.nonNegativeLength - } - } - var markedTextStyle: [NSAttributedString.Key: Any]? - var beginningOfDocument: UITextPosition { - IndexedPosition(index: 0) - } - var endOfDocument: UITextPosition { - IndexedPosition(index: string.length) - } - weak var inputDelegate: UITextInputDelegate? - var hasText: Bool { - string.length > 0 - } - var tokenizer: UITextInputTokenizer { - customTokenizer - } - private lazy var customTokenizer = TextInputStringTokenizer(textInput: self, - stringView: stringView, - lineManager: lineManager, - lineControllerStorage: lineControllerStorage) - var autocorrectionType: UITextAutocorrectionType = .default - var autocapitalizationType: UITextAutocapitalizationType = .sentences - var smartQuotesType: UITextSmartQuotesType = .default - var smartDashesType: UITextSmartDashesType = .default - var smartInsertDeleteType: UITextSmartInsertDeleteType = .default - var spellCheckingType: UITextSpellCheckingType = .default - var keyboardType: UIKeyboardType = .default - var keyboardAppearance: UIKeyboardAppearance = .default - var returnKeyType: UIReturnKeyType = .default - @objc var insertionPointColor: UIColor = .label { - didSet { - if insertionPointColor != oldValue { - updateCaretColor() - } - } - } - @objc var selectionBarColor: UIColor = .label { - didSet { - if selectionBarColor != oldValue { - updateCaretColor() - } - } - } - @objc var selectionHighlightColor: UIColor = .label.withAlphaComponent(0.2) { - didSet { - if selectionHighlightColor != oldValue { - updateCaretColor() - } - } - } - var isEditing = false { - didSet { - if isEditing != oldValue { - layoutManager.isEditing = isEditing - } - } - } - override var undoManager: UndoManager? { - timedUndoManager - } - - // MARK: - Appearance - var theme: Theme { - didSet { - applyThemeToChildren() - } - } - var showLineNumbers = false { - didSet { - if showLineNumbers != oldValue { - caretRectService.showLineNumbers = showLineNumbers - gutterWidthService.showLineNumbers = showLineNumbers - layoutManager.showLineNumbers = showLineNumbers - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var lineSelectionDisplayType: LineSelectionDisplayType { - get { - layoutManager.lineSelectionDisplayType - } - set { - layoutManager.lineSelectionDisplayType = newValue - } - } - var showTabs: Bool { - get { - invisibleCharacterConfiguration.showTabs - } - set { - if newValue != invisibleCharacterConfiguration.showTabs { - invisibleCharacterConfiguration.showTabs = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var showSpaces: Bool { - get { - invisibleCharacterConfiguration.showSpaces - } - set { - if newValue != invisibleCharacterConfiguration.showSpaces { - invisibleCharacterConfiguration.showSpaces = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var showNonBreakingSpaces: Bool { - get { - invisibleCharacterConfiguration.showNonBreakingSpaces - } - set { - if newValue != invisibleCharacterConfiguration.showNonBreakingSpaces { - invisibleCharacterConfiguration.showNonBreakingSpaces = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var showLineBreaks: Bool { - get { - invisibleCharacterConfiguration.showLineBreaks - } - set { - if newValue != invisibleCharacterConfiguration.showLineBreaks { - invisibleCharacterConfiguration.showLineBreaks = newValue - invalidateLines() - layoutManager.setNeedsLayout() - layoutManager.setNeedsDisplayOnLines() - setNeedsLayout() - } - } - } - var showSoftLineBreaks: Bool { - get { - invisibleCharacterConfiguration.showSoftLineBreaks - } - set { - if newValue != invisibleCharacterConfiguration.showSoftLineBreaks { - invisibleCharacterConfiguration.showSoftLineBreaks = newValue - invalidateLines() - layoutManager.setNeedsLayout() - layoutManager.setNeedsDisplayOnLines() - setNeedsLayout() - } - } - } - var tabSymbol: String { - get { - invisibleCharacterConfiguration.tabSymbol - } - set { - if newValue != invisibleCharacterConfiguration.tabSymbol { - invisibleCharacterConfiguration.tabSymbol = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var spaceSymbol: String { - get { - invisibleCharacterConfiguration.spaceSymbol - } - set { - if newValue != invisibleCharacterConfiguration.spaceSymbol { - invisibleCharacterConfiguration.spaceSymbol = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var nonBreakingSpaceSymbol: String { - get { - invisibleCharacterConfiguration.nonBreakingSpaceSymbol - } - set { - if newValue != invisibleCharacterConfiguration.nonBreakingSpaceSymbol { - invisibleCharacterConfiguration.nonBreakingSpaceSymbol = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var lineBreakSymbol: String { - get { - invisibleCharacterConfiguration.lineBreakSymbol - } - set { - if newValue != invisibleCharacterConfiguration.lineBreakSymbol { - invisibleCharacterConfiguration.lineBreakSymbol = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var softLineBreakSymbol: String { - get { - invisibleCharacterConfiguration.softLineBreakSymbol - } - set { - if newValue != invisibleCharacterConfiguration.softLineBreakSymbol { - invisibleCharacterConfiguration.softLineBreakSymbol = newValue - layoutManager.setNeedsDisplayOnLines() - } - } - } - var indentStrategy: IndentStrategy = .tab(length: 2) { - didSet { - if indentStrategy != oldValue { - indentController.indentStrategy = indentStrategy - layoutManager.setNeedsLayout() - setNeedsLayout() - layoutIfNeeded() - } - } - } - var gutterLeadingPadding: CGFloat = 3 { - didSet { - if gutterLeadingPadding != oldValue { - gutterWidthService.gutterLeadingPadding = gutterLeadingPadding - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var gutterTrailingPadding: CGFloat = 3 { - didSet { - if gutterTrailingPadding != oldValue { - gutterWidthService.gutterTrailingPadding = gutterTrailingPadding - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var gutterMinimumCharacterCount: Int = 1 { - didSet { - if gutterMinimumCharacterCount != oldValue { - gutterWidthService.gutterMinimumCharacterCount = gutterMinimumCharacterCount - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var textContainerInset: UIEdgeInsets { - get { - layoutManager.textContainerInset - } - set { - if newValue != layoutManager.textContainerInset { - caretRectService.textContainerInset = newValue - selectionRectService.textContainerInset = newValue - contentSizeService.textContainerInset = newValue - layoutManager.textContainerInset = newValue - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var isLineWrappingEnabled: Bool { - get { - layoutManager.isLineWrappingEnabled - } - set { - if newValue != layoutManager.isLineWrappingEnabled { - contentSizeService.isLineWrappingEnabled = newValue - layoutManager.isLineWrappingEnabled = newValue - invalidateLines() - layoutManager.setNeedsLayout() - layoutManager.layoutIfNeeded() - } - } - } - var lineBreakMode: LineBreakMode = .byWordWrapping { - didSet { - if lineBreakMode != oldValue { - invalidateLines() - contentSizeService.invalidateContentSize() - layoutManager.setNeedsLayout() - layoutManager.layoutIfNeeded() - } - } - } - var gutterWidth: CGFloat { - gutterWidthService.gutterWidth - } - var lineHeightMultiplier: CGFloat = 1 { - didSet { - if lineHeightMultiplier != oldValue { - selectionRectService.lineHeightMultiplier = lineHeightMultiplier - layoutManager.lineHeightMultiplier = lineHeightMultiplier - invalidateLines() - lineManager.estimatedLineHeight = estimatedLineHeight - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var kern: CGFloat = 0 { - didSet { - if kern != oldValue { - invalidateLines() - pageGuideController.kern = kern - contentSizeService.invalidateContentSize() - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var characterPairs: [CharacterPair] = [] { - didSet { - maximumLeadingCharacterPairComponentLength = characterPairs.map(\.leading.utf16.count).max() ?? 0 - } - } - var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode = .disabled - var showPageGuide = false { - didSet { - if showPageGuide != oldValue { - if showPageGuide { - addSubview(pageGuideController.guideView) - sendSubviewToBack(pageGuideController.guideView) - setNeedsLayout() - } else { - pageGuideController.guideView.removeFromSuperview() - setNeedsLayout() - } - } - } - } - var pageGuideColumn: Int { - get { - pageGuideController.column - } - set { - if newValue != pageGuideController.column { - pageGuideController.column = newValue - setNeedsLayout() - } - } - } - private var estimatedLineHeight: CGFloat { - theme.font.totalLineHeight * lineHeightMultiplier - } - var highlightedRanges: [HighlightedRange] { - get { - highlightService.highlightedRanges - } - set { - if newValue != highlightService.highlightedRanges { - highlightService.highlightedRanges = newValue - layoutManager.setNeedsLayout() - layoutManager.layoutIfNeeded() - } - } - } - - // MARK: - Contents - weak var delegate: TextInputViewDelegate? - var string: NSString { - get { - stringView.string - } - set { - if newValue != stringView.string { - stringView.string = newValue - languageMode.parse(newValue) - lineManager.rebuild() - if let oldSelectedRange = selectedRange { - inputDelegate?.selectionWillChange(self) - selectedRange = safeSelectionRange(from: oldSelectedRange) - inputDelegate?.selectionDidChange(self) - } - contentSizeService.invalidateContentSize() - gutterWidthService.invalidateLineNumberWidth() - invalidateLines() - layoutManager.setNeedsLayout() - layoutManager.layoutIfNeeded() - if !preserveUndoStackWhenSettingString { - undoManager?.removeAllActions() - } - } - } - } - var viewport: CGRect { - get { - layoutManager.viewport - } - set { - if newValue != layoutManager.viewport { - layoutManager.viewport = newValue - layoutManager.setNeedsLayout() - setNeedsLayout() - } - } - } - var scrollViewWidth: CGFloat = 0 { - didSet { - if scrollViewWidth != oldValue { - contentSizeService.scrollViewWidth = scrollViewWidth - layoutManager.scrollViewWidth = scrollViewWidth - if isLineWrappingEnabled { - invalidateLines() - } - } - } - } - var contentSize: CGSize { - contentSizeService.contentSize - } - var selectedRange: NSRange? { - get { - _selectedRange - } - set { - if newValue != _selectedRange { - _selectedRange = newValue - delegate?.textInputViewDidChangeSelection(self) - } - } - } - private var _selectedRange: NSRange? { - didSet { - if _selectedRange != oldValue { - layoutManager.selectedRange = _selectedRange - layoutManager.setNeedsLayoutLineSelection() - setNeedsLayout() - } - } - } - override var canBecomeFirstResponder: Bool { - true - } - weak var gutterParentView: UIView? { - get { - layoutManager.gutterParentView - } - set { - layoutManager.gutterParentView = newValue - } - } - var scrollViewSafeAreaInsets: UIEdgeInsets = .zero { - didSet { - if scrollViewSafeAreaInsets != oldValue { - layoutManager.safeAreaInsets = scrollViewSafeAreaInsets - } - } - } - var gutterContainerView: UIView { - layoutManager.gutterContainerView - } - private(set) var stringView = StringView() { - didSet { - if stringView !== oldValue { - caretRectService.stringView = stringView - lineManager.stringView = stringView - lineControllerFactory.stringView = stringView - lineControllerStorage.stringView = stringView - layoutManager.stringView = stringView - indentController.stringView = stringView - lineMovementController.stringView = stringView - customTokenizer.stringView = stringView - } - } - } - private(set) var lineManager: LineManager { - didSet { - if lineManager !== oldValue { - indentController.lineManager = lineManager - lineMovementController.lineManager = lineManager - gutterWidthService.lineManager = lineManager - contentSizeService.lineManager = lineManager - caretRectService.lineManager = lineManager - selectionRectService.lineManager = lineManager - highlightService.lineManager = lineManager - customTokenizer.lineManager = lineManager - } - } - } - var viewHierarchyContainsCaret: Bool { - textSelectionView?.subviews.count == 1 - } - var lineEndings: LineEnding = .lf - private(set) var isRestoringPreviouslyDeletedText = false - - // MARK: - Private - private var languageMode: InternalLanguageMode = PlainTextInternalLanguageMode() { - didSet { - if languageMode !== oldValue { - indentController.languageMode = languageMode - if let treeSitterLanguageMode = languageMode as? TreeSitterInternalLanguageMode { - treeSitterLanguageMode.delegate = self - } - } - } - } - private let lineControllerFactory: LineControllerFactory - private let lineControllerStorage: LineControllerStorage - private let layoutManager: LayoutManager - private let timedUndoManager = TimedUndoManager() - private let indentController: IndentController - private let lineMovementController: LineMovementController - private let pageGuideController = PageGuideController() - private let gutterWidthService: GutterWidthService - private let contentSizeService: ContentSizeService - private let caretRectService: CaretRectService - private let selectionRectService: SelectionRectService - private let highlightService: HighlightService - private let invisibleCharacterConfiguration = InvisibleCharacterConfiguration() - private var markedRange: NSRange? { - get { - layoutManager.markedRange - } - set { - layoutManager.markedRange = newValue - } - } - private var floatingCaretView: FloatingCaretView? - private var insertionPointColorBeforeFloatingBegan: UIColor = .label - private var maximumLeadingCharacterPairComponentLength = 0 - private var textSelectionView: UIView? { - if let klass = NSClassFromString("UITextSelectionView") { - return subviews.first { $0.isKind(of: klass) } - } else { - return nil - } - } - private var hasPendingFullLayout = false - private let editMenuController = EditMenuController() - 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] = [] - - // MARK: - Lifecycle - init(theme: Theme) { - self.theme = theme - lineManager = LineManager(stringView: stringView) - highlightService = HighlightService(lineManager: lineManager) - lineControllerFactory = LineControllerFactory(stringView: stringView, - highlightService: highlightService, - invisibleCharacterConfiguration: invisibleCharacterConfiguration) - lineControllerStorage = LineControllerStorage(stringView: stringView, lineControllerFactory: lineControllerFactory) - gutterWidthService = GutterWidthService(lineManager: lineManager) - contentSizeService = ContentSizeService(lineManager: lineManager, - lineControllerStorage: lineControllerStorage, - gutterWidthService: gutterWidthService, - invisibleCharacterConfiguration: invisibleCharacterConfiguration) - caretRectService = CaretRectService(stringView: stringView, - lineManager: lineManager, - lineControllerStorage: lineControllerStorage, - gutterWidthService: gutterWidthService) - selectionRectService = SelectionRectService(lineManager: lineManager, - contentSizeService: contentSizeService, - gutterWidthService: gutterWidthService, - caretRectService: caretRectService) - layoutManager = LayoutManager(lineManager: lineManager, - languageMode: languageMode, - stringView: stringView, - lineControllerStorage: lineControllerStorage, - contentSizeService: contentSizeService, - gutterWidthService: gutterWidthService, - caretRectService: caretRectService, - selectionRectService: selectionRectService, - highlightService: highlightService, - invisibleCharacterConfiguration: invisibleCharacterConfiguration) - 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) - applyThemeToChildren() - indentController.delegate = self - lineControllerStorage.delegate = self - gutterWidthService.gutterLeadingPadding = gutterLeadingPadding - gutterWidthService.gutterTrailingPadding = gutterTrailingPadding - layoutManager.delegate = self - layoutManager.textInputView = self - editMenuController.delegate = self - editMenuController.setupEditMenu(in: self) - setupContentSizeObserver() - setupGutterWidthObserver() - } - - override func becomeFirstResponder() -> Bool { - if canBecomeFirstResponder { - delegate?.textInputViewWillBeginEditing(self) - } - let didBecomeFirstResponder = super.becomeFirstResponder() - if didBecomeFirstResponder { - delegate?.textInputViewDidBeginEditing(self) - } else { - // This is called in the case where: - // 1. The view is the first responder. - // 2. A view is presented modally on top of the editor. - // 3. The modally presented view is dismissed. - // 4. The responder chain attempts to make the text view first responder again but super.becomeFirstResponder() returns false. - delegate?.textInputViewDidCancelBeginEditing(self) - } - return didBecomeFirstResponder - } - - override func resignFirstResponder() -> Bool { - let didResignFirstResponder = super.resignFirstResponder() - if didResignFirstResponder { - delegate?.textInputViewDidEndEditing(self) - } - return didResignFirstResponder - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - hasDeletedTextWithPendingLayoutSubviews = false - layoutManager.layoutIfNeeded() - layoutManager.layoutLineSelectionIfNeeded() - layoutPageGuideIfNeeded() - // 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 notifyInputDelegateAboutSelectionChangeInLayoutSubviews { - inputDelegate?.selectionWillChange(self) - inputDelegate?.selectionDidChange(self) - } - if notifyDelegateAboutSelectionChangeInLayoutSubviews { - notifyDelegateAboutSelectionChangeInLayoutSubviews = false - delegate?.textInputViewDidChangeSelection(self) - } - } - - override func copy(_ sender: Any?) { - if let selectedTextRange = selectedTextRange, let text = text(in: selectedTextRange) { - UIPasteboard.general.string = text - } - } - - override func paste(_ sender: Any?) { - if let selectedTextRange = selectedTextRange, let string = UIPasteboard.general.string { - inputDelegate?.selectionWillChange(self) - let preparedText = prepareTextForInsertion(string) - replace(selectedTextRange, withText: preparedText) - inputDelegate?.selectionDidChange(self) - } - } - - override func cut(_ sender: Any?) { - if let selectedTextRange = selectedTextRange, let text = text(in: selectedTextRange) { - UIPasteboard.general.string = text - replace(selectedTextRange, withText: "") - } - } - - override func selectAll(_ sender: Any?) { - notifyInputDelegateAboutSelectionChangeInLayoutSubviews = true - selectedRange = NSRange(location: 0, length: string.length) - } - - /// When autocorrection is enabled and the user tap on a misspelled word, UITextInteraction will present - /// a UIMenuController with suggestions for the correct spelling of the word. Selecting a suggestion will - /// cause UITextInteraction to call the non-existing -replace(_:) function and pass an instance of the private - /// UITextReplacement type as parameter. We can't make autocorrection work properly without using private API. - @objc func replace(_ obj: NSObject) { - if let replacementText = obj.value(forKey: "_repl" + "Ttnemeca".reversed() + "ext") as? String { - if let indexedRange = obj.value(forKey: "_r" + "gna".reversed() + "e") as? IndexedRange { - replace(indexedRange, withText: replacementText) - } - } - } - - override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { - if action == #selector(copy(_:)) { - if let selectedTextRange = selectedTextRange { - return !selectedTextRange.isEmpty - } else { - return false - } - } else if action == #selector(cut(_:)) { - if let selectedTextRange = selectedTextRange { - return isEditing && !selectedTextRange.isEmpty - } else { - return false - } - } else if action == #selector(paste(_:)) { - return isEditing && UIPasteboard.general.hasStrings - } else if action == #selector(selectAll(_:)) { - return true - } else if action == #selector(replace(_:)) { - return true - } else if action == NSSelectorFromString("replaceTextInSelectedHighlightedRange") { - if let selectedRange = selectedRange, let highlightedRange = highlightedRange(for: selectedRange) { - return delegate?.textInputView(self, canReplaceTextIn: highlightedRange) ?? false - } else { - return false - } - } else { - return super.canPerformAction(action, withSender: sender) - } - } - - func linePosition(at location: Int) -> LinePosition? { - lineManager.linePosition(at: location) - } - - func setState(_ state: TextViewState, addUndoAction: Bool = false) { - let oldText = stringView.string - let newText = state.stringView.string - stringView = state.stringView - theme = state.theme - languageMode = state.languageMode - lineControllerStorage.removeAllLineControllers() - lineManager = state.lineManager - lineManager.estimatedLineHeight = estimatedLineHeight - layoutManager.languageMode = state.languageMode - layoutManager.lineManager = state.lineManager - contentSizeService.invalidateContentSize() - gutterWidthService.invalidateLineNumberWidth() - if addUndoAction { - if newText != oldText { - let newRange = NSRange(location: 0, length: newText.length) - timedUndoManager.endUndoGrouping() - timedUndoManager.beginUndoGrouping() - addUndoOperation(replacing: newRange, withText: oldText as String) - timedUndoManager.endUndoGrouping() - } - } else { - timedUndoManager.removeAllActions() - } - if let oldSelectedRange = selectedRange { - inputDelegate?.selectionWillChange(self) - selectedRange = safeSelectionRange(from: oldSelectedRange) - inputDelegate?.selectionDidChange(self) - } - if window != nil { - performFullLayout() - } else { - hasPendingFullLayout = true - } - } - - func clearSelection() { - selectedRange = nil - } - - func moveCaret(to point: CGPoint) { - if let index = layoutManager.closestIndex(to: point) { - selectedRange = NSRange(location: index, length: 0) - } - } - - func setLanguageMode(_ languageMode: LanguageMode, completion: ((Bool) -> Void)? = nil) { - let internalLanguageMode = InternalLanguageModeFactory.internalLanguageMode( - from: languageMode, - stringView: stringView, - lineManager: lineManager) - self.languageMode = internalLanguageMode - layoutManager.languageMode = internalLanguageMode - internalLanguageMode.parse(string) { [weak self] finished in - if let self = self, finished { - self.invalidateLines() - self.layoutManager.setNeedsLayout() - self.layoutManager.layoutIfNeeded() - } - completion?(finished) - } - } - - func syntaxNode(at location: Int) -> SyntaxNode? { - if let linePosition = lineManager.linePosition(at: location) { - return languageMode.syntaxNode(at: linePosition) - } else { - return nil - } - } - - func isIndentation(at location: Int) -> Bool { - guard let line = lineManager.line(containingCharacterAt: location) else { - return false - } - let localLocation = location - line.location - guard localLocation >= 0 else { - return false - } - let indentLevel = languageMode.currentIndentLevel(of: line, using: indentStrategy) - let indentString = indentStrategy.string(indentLevel: indentLevel) - return localLocation <= indentString.utf16.count - } - - func detectIndentStrategy() -> DetectedIndentStrategy { - languageMode.detectIndentStrategy() - } - - func textPreview(containing range: NSRange) -> TextPreview? { - layoutManager.textPreview(containing: range) - } - - func layoutLines(toLocation location: Int) { - layoutManager.layoutLines(toLocation: location) - } - - func redisplayVisibleLines() { - layoutManager.redisplayVisibleLines() - } - - override func didMoveToWindow() { - super.didMoveToWindow() - if hasPendingFullLayout && window != nil { - hasPendingFullLayout = false - performFullLayout() - } - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - // We end our current undo group when the user touches the view. - let result = super.hitTest(point, with: event) - if result === self { - timedUndoManager.endUndoGrouping() - } - return result - } - - override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - super.traitCollectionDidChange(previousTraitCollection) - if traitCollection.hasDifferentColorAppearance(comparedTo: previousTraitCollection) { - invalidateLines() - layoutManager.setNeedsLayout() - } - } - - override func pressesEnded(_ presses: Set, with event: UIPressesEvent?) { - super.pressesEnded(presses, with: event) - if let keyCode = presses.first?.key?.keyCode, presses.count == 1 { - if markedRange != nil { - handleKeyPressDuringMultistageTextInput(keyCode: keyCode) - } - } - } -} - -// MARK: - Theming -private extension TextInputView { - private func applyThemeToChildren() { - gutterWidthService.font = theme.lineNumberFont - lineManager.estimatedLineHeight = estimatedLineHeight - indentController.indentFont = theme.font - pageGuideController.font = theme.font - pageGuideController.guideView.hairlineWidth = theme.pageGuideHairlineWidth - pageGuideController.guideView.hairlineColor = theme.pageGuideHairlineColor - pageGuideController.guideView.backgroundColor = theme.pageGuideBackgroundColor - layoutManager.theme = theme - } -} - -// MARK: - Navigation -private extension TextInputView { - 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 - // on macOS and I think that works quite well for plain text editors on iOS too. - guard let markedRange = markedRange, let markedText = stringView.substring(in: markedRange) else { - return - } - // We only unmark the text if the marked text contains specific characters only. - // Some languages use multistage text input extensively and for those iOS presents a UI when - // navigating with the arrow keys. We do not want to interfere with that interaction. - let characterSet = CharacterSet(charactersIn: "`´^¨") - guard markedText.rangeOfCharacter(from: characterSet.inverted) == nil else { - return - } - switch keyCode { - case .keyboardUpArrow: - navigate(in: .up, offset: 1) - unmarkText() - case .keyboardRightArrow: - navigate(in: .right, offset: 1) - unmarkText() - case .keyboardDownArrow: - navigate(in: .down, offset: 1) - unmarkText() - case .keyboardLeftArrow: - navigate(in: .left, offset: 1) - unmarkText() - case .keyboardEscape: - unmarkText() - default: - break - } - } - - private func navigate(in direction: UITextLayoutDirection, offset: Int) { - if let selectedRange = selectedRange { - if let location = lineMovementController.location(from: selectedRange.location, in: direction, offset: offset) { - self.selectedRange = NSRange(location: location, length: 0) - } - } - } -} - -// MARK: - Layout -private extension TextInputView { - private func layoutPageGuideIfNeeded() { - if showPageGuide { - // The width extension is used to make the page guide look "attached" to the right hand side, even when the scroll view bouncing on the right side. - let maxContentOffsetX = contentSizeService.contentWidth - viewport.width - let widthExtension = max(ceil(viewport.minX - maxContentOffsetX), 0) - let xPosition = gutterWidthService.gutterWidth + textContainerInset.left + pageGuideController.columnOffset - let width = max(bounds.width - xPosition + widthExtension, 0) - let orrigin = CGPoint(x: xPosition, y: viewport.minY) - let pageGuideSize = CGSize(width: width, height: viewport.height) - pageGuideController.guideView.frame = CGRect(origin: orrigin, size: pageGuideSize) - } - } - - private func performFullLayout() { - 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() - } - } - - private func setupContentSizeObserver() { - contentSizeService.$isContentSizeInvalid.filter { $0 }.sink { [weak self] _ in - if let self = self { - self.delegate?.textInputViewDidInvalidateContentSize(self) - } - }.store(in: &cancellables) - } - - private func setupGutterWidthObserver() { - gutterWidthService.didUpdateGutterWidth.sink { [weak self] in - if let self = self { - // Typeset lines again when the line number width changes since changing line number width may increase or reduce the number of line fragments in a line. - self.setNeedsLayout() - self.invalidateLines() - self.layoutManager.setNeedsLayout() - self.delegate?.textInputViewDidChangeGutterWidth(self) - } - }.store(in: &cancellables) - } -} - -// MARK: - Floating Caret -extension TextInputView { - func beginFloatingCursor(at point: CGPoint) { - if floatingCaretView == nil, let position = closestPosition(to: point) { - insertionPointColorBeforeFloatingBegan = insertionPointColor - insertionPointColor = insertionPointColorBeforeFloatingBegan.withAlphaComponent(0.5) - updateCaretColor() - let caretRect = self.caretRect(for: position) - let caretOrigin = CGPoint(x: point.x - caretRect.width / 2, y: point.y - caretRect.height / 2) - let floatingCaretView = FloatingCaretView() - floatingCaretView.backgroundColor = insertionPointColorBeforeFloatingBegan - floatingCaretView.frame = CGRect(origin: caretOrigin, size: caretRect.size) - addSubview(floatingCaretView) - self.floatingCaretView = floatingCaretView - delegate?.textInputViewDidBeginFloatingCursor(self) - } - } - - func updateFloatingCursor(at point: CGPoint) { - if let floatingCaretView = floatingCaretView { - let caretSize = floatingCaretView.frame.size - let caretOrigin = CGPoint(x: point.x - caretSize.width / 2, y: point.y - caretSize.height / 2) - floatingCaretView.frame = CGRect(origin: caretOrigin, size: caretSize) - } - } - - func endFloatingCursor() { - insertionPointColor = insertionPointColorBeforeFloatingBegan - updateCaretColor() - floatingCaretView?.removeFromSuperview() - floatingCaretView = nil - delegate?.textInputViewDidEndFloatingCursor(self) - } - - private func updateCaretColor() { - // Removing the UITextSelectionView and re-adding it forces it to query the insertion point color. - if let textSelectionView = textSelectionView { - textSelectionView.removeFromSuperview() - addSubview(textSelectionView) - } - } -} - -// MARK: - Rects -extension TextInputView { - func caretRect(for position: UITextPosition) -> CGRect { - guard let indexedPosition = position as? IndexedPosition else { - fatalError("Expected position to be of type \(IndexedPosition.self)") - } - return caretRectService.caretRect(at: indexedPosition.index, allowMovingCaretToNextLineFragment: true) - } - - func caretRect(at location: Int) -> CGRect { - caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: true) - } - - func firstRect(for range: UITextRange) -> CGRect { - guard let indexedRange = range as? IndexedRange else { - fatalError("Expected range to be of type \(IndexedRange.self)") - } - return layoutManager.firstRect(for: indexedRange.range) - } -} - -// MARK: - Editing -extension TextInputView { - func insertText(_ text: String) { - let preparedText = prepareTextForInsertion(text) - isRestoringPreviouslyDeletedText = hasDeletedTextWithPendingLayoutSubviews - hasDeletedTextWithPendingLayoutSubviews = false - defer { - isRestoringPreviouslyDeletedText = false - } - // If there is no marked range or selected range then we fallback to appending text to the end of our string. - let selectedRange = markedRange ?? selectedRange ?? NSRange(location: stringView.string.length, length: 0) - guard shouldChangeText(in: selectedRange, replacementText: preparedText) else { - isRestoringPreviouslyDeletedText = false - return - } - // If we're inserting text then we can't have a marked range. However, UITextInput doesn't always clear the marked range - // before calling -insertText(_:), so we do it manually. This issue can be tested by entering a backtick (`) in an empty - // document, then pressing any arrow key (up, right, down or left) followed by the return key. - // The backtick will remain marked unless we manually clear the marked range. - markedRange = nil - if LineEnding(symbol: text) != nil { - indentController.insertLineBreak(in: selectedRange, using: lineEndings) - layoutIfNeeded() - delegate?.textInputViewDidChangeSelection(self) - } else { - replaceText(in: selectedRange, with: preparedText) - layoutIfNeeded() - delegate?.textInputViewDidChangeSelection(self) - } - } - - func deleteBackward() { - didCallDeleteBackward = true - guard let selectedRange = markedRange ?? selectedRange, selectedRange.length > 0 else { - return - } - let deleteRange = rangeForDeletingText(in: selectedRange) - // If we're deleting everything in the marked range then we clear the marked range. UITextInput doesn't do that for us. - // Can be tested by entering a backtick (`) in an empty document and deleting it. - if deleteRange == markedRange { - markedRange = nil - } - guard shouldChangeText(in: deleteRange, replacementText: "") else { - return - } - // Set a flag indicating that we have deleted text. This is reset in -layoutSubviews() but if this has not been reset before insertText() is called, then UIKit deleted characters prior to inserting combined characters. This happens when UIKit turns Korean characters into a single character. E.g. when typing ㅇ followed by ㅓ UIKit will perform the following operations: - // 1. Delete ㅇ. - // 2. Delete the character before ㅇ. I'm unsure why this is needed. - // 3. Insert the character that was previously before ㅇ. - // 4. Insert the ㅇ and ㅓ but combined into the single character delete ㅇ and then insert 어. - // We can detect this case in insertText() by checking if this variable is true. - hasDeletedTextWithPendingLayoutSubviews = true - // Disable notifying delegate in layout subviews to prevent sending the selected range with length > 0 when deleting text. This aligns with the behavior of UITextView and was introduced to resolve issue #158: https://github.com/simonbs/Runestone/issues/158 - notifyDelegateAboutSelectionChangeInLayoutSubviews = false - // Disable notifying input 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. - 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 - if deleteRange.length == 1 { - selectedRangeAfterUndo = NSRange(location: selectedRange.upperBound, length: 0) - } else { - selectedRangeAfterUndo = selectedRange - } - let isDeletingMultipleCharacters = selectedRange.length > 1 - if isDeletingMultipleCharacters { - timedUndoManager.endUndoGrouping() - timedUndoManager.beginUndoGrouping() - } - replaceText(in: deleteRange, with: "", selectedRangeAfterUndo: selectedRangeAfterUndo) - // Sending selection changed without calling the input delegate directly. This ensures that both inputting Korean letters and deleting entire words with Option+Backspace works properly. - sendSelectionChangedToTextSelectionView() - if isDeletingMultipleCharacters { - timedUndoManager.endUndoGrouping() - } - delegate?.textInputViewDidChangeSelection(self) - } - - func replace(_ range: UITextRange, withText text: String) { - let preparedText = prepareTextForInsertion(text) - if let indexedRange = range as? IndexedRange, shouldChangeText(in: indexedRange.range.nonNegativeLength, replacementText: preparedText) { - replaceText(in: indexedRange.range.nonNegativeLength, with: preparedText) - delegate?.textInputViewDidChangeSelection(self) - } - } - - func replaceText(in batchReplaceSet: BatchReplaceSet) { - guard !batchReplaceSet.replacements.isEmpty else { - return - } - var oldLinePosition: LinePosition? - if let oldSelectedRange = selectedRange { - oldLinePosition = lineManager.linePosition(at: oldSelectedRange.location) - } - let textEditHelper = TextEditHelper(stringView: stringView, lineManager: lineManager, lineEndings: lineEndings) - let newString = textEditHelper.string(byApplying: batchReplaceSet) - setStringWithUndoAction(newString) - if let oldLinePosition = oldLinePosition { - // By restoring the selected range using the old line position we can better preserve the old selected language. - moveCaret(to: oldLinePosition) - } - } - - func text(in range: UITextRange) -> String? { - if let indexedRange = range as? IndexedRange { - return text(in: indexedRange.range.nonNegativeLength) - } else { - return nil - } - } - - func text(in range: NSRange) -> String? { - stringView.substring(in: range) - } - - private func setStringWithUndoAction(_ newString: NSString) { - guard newString != string else { - return - } - guard let oldString = stringView.string.copy() as? NSString else { - return - } - timedUndoManager.endUndoGrouping() - let oldSelectedRange = selectedRange - preserveUndoStackWhenSettingString = true - string = newString - preserveUndoStackWhenSettingString = false - timedUndoManager.beginUndoGrouping() - timedUndoManager.setActionName(L10n.Undo.ActionName.replaceAll) - timedUndoManager.registerUndo(withTarget: self) { textInputView in - textInputView.setStringWithUndoAction(oldString) - } - timedUndoManager.endUndoGrouping() - delegate?.textInputViewDidChange(self) - if let oldSelectedRange = oldSelectedRange { - selectedRange = safeSelectionRange(from: oldSelectedRange) - } - } - - private func rangeForDeletingText(in range: NSRange) -> NSRange { - var resultingRange = range - if range.length == 1, let indentRange = indentController.indentRangeInFrontOfLocation(range.upperBound) { - resultingRange = indentRange - } else { - resultingRange = string.customRangeOfComposedCharacterSequences(for: range) - } - // If deleting the leading component of a character pair we may also expand the range to delete the trailing component. - if characterPairTrailingComponentDeletionMode == .immediatelyFollowingLeadingComponent - && maximumLeadingCharacterPairComponentLength > 0 - && resultingRange.length <= maximumLeadingCharacterPairComponentLength { - let stringToDelete = stringView.substring(in: resultingRange) - if let characterPair = characterPairs.first(where: { $0.leading == stringToDelete }) { - let trailingComponentLength = characterPair.trailing.utf16.count - let trailingComponentRange = NSRange(location: resultingRange.upperBound, length: trailingComponentLength) - if stringView.substring(in: trailingComponentRange) == characterPair.trailing { - let deleteRange = trailingComponentRange.upperBound - resultingRange.lowerBound - resultingRange = NSRange(location: resultingRange.lowerBound, length: deleteRange) - } - } - } - return resultingRange - } - - private func replaceText(in range: NSRange, - with newString: String, - selectedRangeAfterUndo: NSRange? = nil, - undoActionName: String = L10n.Undo.ActionName.typing) { - let nsNewString = newString as NSString - let currentText = text(in: range) ?? "" - let newRange = NSRange(location: range.location, length: nsNewString.length) - addUndoOperation(replacing: newRange, withText: currentText, selectedRangeAfterUndo: selectedRangeAfterUndo, actionName: undoActionName) - _selectedRange = NSRange(location: newRange.upperBound, length: 0) - let textEditHelper = TextEditHelper(stringView: stringView, lineManager: lineManager, lineEndings: lineEndings) - let textEditResult = textEditHelper.replaceText(in: range, with: newString) - let textChange = textEditResult.textChange - let lineChangeSet = textEditResult.lineChangeSet - let languageModeLineChangeSet = languageMode.textDidChange(textChange) - lineChangeSet.union(with: languageModeLineChangeSet) - applyLineChangesToLayoutManager(lineChangeSet) - let updatedTextEditResult = TextEditResult(textChange: textChange, lineChangeSet: lineChangeSet) - delegate?.textInputViewDidChange(self) - if updatedTextEditResult.didAddOrRemoveLines { - delegate?.textInputViewDidInvalidateContentSize(self) - } - } - - private func applyLineChangesToLayoutManager(_ lineChangeSet: LineChangeSet) { - let didAddOrRemoveLines = !lineChangeSet.insertedLines.isEmpty || !lineChangeSet.removedLines.isEmpty - if didAddOrRemoveLines { - contentSizeService.invalidateContentSize() - for removedLine in lineChangeSet.removedLines { - lineControllerStorage.removeLineController(withID: removedLine.id) - contentSizeService.removeLine(withID: removedLine.id) - } - } - let editedLineIDs = Set(lineChangeSet.editedLines.map(\.id)) - layoutManager.redisplayLines(withIDs: editedLineIDs) - if didAddOrRemoveLines { - gutterWidthService.invalidateLineNumberWidth() - } - layoutManager.setNeedsLayout() - layoutManager.layoutIfNeeded() - } - - private func shouldChangeText(in range: NSRange, replacementText text: String) -> Bool { - delegate?.textInputView(self, shouldChangeTextIn: range, replacementText: text) ?? true - } - - private func addUndoOperation(replacing range: NSRange, - withText text: String, - selectedRangeAfterUndo: NSRange? = nil, - actionName: String = L10n.Undo.ActionName.typing) { - let oldSelectedRange = selectedRangeAfterUndo ?? selectedRange - timedUndoManager.beginUndoGrouping() - timedUndoManager.setActionName(actionName) - timedUndoManager.registerUndo(withTarget: self) { textInputView in - textInputView.inputDelegate?.selectionWillChange(textInputView) - textInputView.replaceText(in: range, with: text) - textInputView.selectedRange = oldSelectedRange - textInputView.inputDelegate?.selectionDidChange(textInputView) - } - } - - private func prepareTextForInsertion(_ text: String) -> String { - // Ensure all line endings match our preferred line endings. - let lines = text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) - return lines.joined(separator: lineEndings.symbol) - } -} - -// MARK: - Selection -extension TextInputView { - func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { - if let indexedRange = range as? IndexedRange { - return selectionRectService.selectionRects(in: indexedRange.range.nonNegativeLength) - } else { - return [] - } - } - - private func safeSelectionRange(from range: NSRange) -> NSRange { - let stringLength = stringView.string.length - let cappedLocation = min(max(range.location, 0), stringLength) - let cappedLength = min(max(range.length, 0), stringLength - cappedLocation) - return NSRange(location: cappedLocation, length: cappedLength) - } - - private func moveCaret(to linePosition: LinePosition) { - if linePosition.row < lineManager.lineCount { - let line = lineManager.line(atRow: linePosition.row) - let location = line.location + min(linePosition.column, line.data.length) - selectedRange = NSRange(location: location, length: 0) - } else { - selectedRange = nil - } - } - - private func sendSelectionChangedToTextSelectionView() { - // The only way I've found to get the selection change to be reflected properly while still supporting Korean, Chinese, and deleting words with Option+Backspace is to call a private API in some cases. However, as pointed out by Alexander Blach in the following PR, there is another workaround to the issue. - // When passing nil to the input delete, the text selection is update but the text input ignores it. - // Even the Swift Playgrounds app does not get this right for all languages in all cases, so there seems to be some workarounds needed to due bugs in internal classes in UIKit that communicate with instances of UITextInput. - inputDelegate?.selectionDidChange(nil) - } -} - -// MARK: - Indent and Outdent -extension TextInputView { - func shiftLeft() { - if let selectedRange = selectedRange { - inputDelegate?.textWillChange(self) - indentController.shiftLeft(in: selectedRange) - inputDelegate?.textDidChange(self) - } - } - - func shiftRight() { - if let selectedRange = selectedRange { - inputDelegate?.textWillChange(self) - indentController.shiftRight(in: selectedRange) - inputDelegate?.textDidChange(self) - } - } -} - -// MARK: - Move Lines -extension TextInputView { - func moveSelectedLinesUp() { - moveSelectedLine(byOffset: -1, undoActionName: L10n.Undo.ActionName.moveLinesUp) - } - - func moveSelectedLinesDown() { - moveSelectedLine(byOffset: 1, undoActionName: L10n.Undo.ActionName.moveLinesDown) - } - - private func moveSelectedLine(byOffset lineOffset: Int, undoActionName: String) { - guard let oldSelectedRange = selectedRange else { - return - } - let moveLinesService = MoveLinesService(stringView: stringView, lineManager: lineManager, lineEndingSymbol: lineEndings.symbol) - guard let operation = moveLinesService.operationForMovingLines(in: oldSelectedRange, byOffset: lineOffset) else { - return - } - timedUndoManager.endUndoGrouping() - timedUndoManager.beginUndoGrouping() - replaceText(in: operation.removeRange, with: "", undoActionName: undoActionName) - replaceText(in: operation.replacementRange, with: operation.replacementString, undoActionName: undoActionName) - notifyInputDelegateAboutSelectionChangeInLayoutSubviews = true - selectedRange = operation.selectedRange - timedUndoManager.endUndoGrouping() - } -} - -// MARK: - Marking -extension TextInputView { - func setMarkedText(_ markedText: String?, selectedRange: NSRange) { - guard let range = markedRange ?? self.selectedRange else { - return - } - let markedText = markedText ?? "" - guard shouldChangeText(in: range, replacementText: markedText) else { - return - } - 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. - let preferredSelectedRange = NSRange(location: range.location + selectedRange.location, length: selectedRange.length) - inputDelegate?.selectionWillChange(self) - _selectedRange = safeSelectionRange(from: preferredSelectedRange) - inputDelegate?.selectionDidChange(self) - delegate?.textInputViewDidUpdateMarkedRange(self) - } - - func unmarkText() { - inputDelegate?.selectionWillChange(self) - markedRange = nil - inputDelegate?.selectionDidChange(self) - delegate?.textInputViewDidUpdateMarkedRange(self) - } -} - -// MARK: - Ranges and Positions -extension TextInputView { - func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { - // This implementation seems to match the behavior of UITextView. - guard let indexedRange = range as? IndexedRange else { - return nil - } - switch direction { - case .left, .up: - return IndexedPosition(index: indexedRange.range.lowerBound) - case .right, .down: - return IndexedPosition(index: indexedRange.range.upperBound) - @unknown default: - return nil - } - } - - func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? { - guard let indexedPosition = position as? IndexedPosition else { - return nil - } - didCallPositionFromPositionInDirectionWithOffset = true - guard let newLocation = lineMovementController.location(from: indexedPosition.index, in: direction, offset: offset) else { - return nil - } - return IndexedPosition(index: newLocation) - } - - func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { - // This implementation seems to match the behavior of UITextView. - guard let indexedPosition = position as? IndexedPosition else { - return nil - } - switch direction { - case .left, .up: - let leftIndex = max(indexedPosition.index - 1, 0) - return IndexedRange(location: leftIndex, length: indexedPosition.index - leftIndex) - case .right, .down: - let rightIndex = min(indexedPosition.index + 1, stringView.string.length) - return IndexedRange(location: indexedPosition.index, length: rightIndex - indexedPosition.index) - @unknown default: - return nil - } - } - - func characterRange(at point: CGPoint) -> UITextRange? { - guard let index = layoutManager.closestIndex(to: point) else { - return nil - } - let cappedIndex = max(index - 1, 0) - let range = stringView.string.customRangeOfComposedCharacterSequence(at: cappedIndex) - return IndexedRange(range) - } - - func closestPosition(to point: CGPoint) -> UITextPosition? { - if let index = layoutManager.closestIndex(to: point) { - return IndexedPosition(index: index) - } else { - return nil - } - } - - func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { - guard let indexedRange = range as? IndexedRange else { - return nil - } - guard let index = layoutManager.closestIndex(to: point) else { - return nil - } - let minimumIndex = indexedRange.range.lowerBound - let maximumIndex = indexedRange.range.upperBound - let cappedIndex = min(max(index, minimumIndex), maximumIndex) - return IndexedPosition(index: cappedIndex) - } - - func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { - guard let fromIndexedPosition = fromPosition as? IndexedPosition, let toIndexedPosition = toPosition as? IndexedPosition else { - return nil - } - let range = NSRange(location: fromIndexedPosition.index, length: toIndexedPosition.index - fromIndexedPosition.index) - return IndexedRange(range) - } - - func position(from position: UITextPosition, offset: Int) -> UITextPosition? { - guard let indexedPosition = position as? IndexedPosition else { - return nil - } - let newPosition = indexedPosition.index + offset - guard newPosition >= 0 && newPosition <= string.length else { - return nil - } - return IndexedPosition(index: newPosition) - } - - func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { - guard let indexedPosition = position as? IndexedPosition, let otherIndexedPosition = other as? IndexedPosition else { - #if targetEnvironment(macCatalyst) - // Mac Catalyst may pass to `position`. I'm not sure what the right way to deal with that is but returning .orderedSame seems to work. - return .orderedSame - #else - fatalError("Positions must be of type \(IndexedPosition.self)") - #endif - } - if indexedPosition.index < otherIndexedPosition.index { - return .orderedAscending - } else if indexedPosition.index > otherIndexedPosition.index { - return .orderedDescending - } else { - return .orderedSame - } - } - - func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { - if let fromPosition = from as? IndexedPosition, let toPosition = toPosition as? IndexedPosition { - return toPosition.index - fromPosition.index - } else { - return 0 - } - } -} - -// MARK: - Writing Direction -extension TextInputView { - func baseWritingDirection(for position: UITextPosition, in direction: UITextStorageDirection) -> NSWritingDirection { - .natural - } - - func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) {} -} - -// MARK: - UIEditMenuInteraction -extension TextInputView { - func editMenu(for textRange: UITextRange, suggestedActions: [UIMenuElement]) -> UIMenu? { - editMenuController.editMenu(for: textRange, suggestedActions: suggestedActions) - } - - func presentEditMenuForText(in range: NSRange) { - editMenuController.presentEditMenu(from: self, forTextIn: range) - } - - @objc private func replaceTextInSelectedHighlightedRange() { - if let selectedRange = selectedRange, let highlightedRange = highlightedRange(for: selectedRange) { - delegate?.textInputView(self, replaceTextIn: highlightedRange) - } - } - - private func highlightedRange(for range: NSRange) -> HighlightedRange? { - highlightedRanges.first { $0.range == range } - } -} - -// MARK: - TreeSitterLanguageModeDeleage -extension TextInputView: TreeSitterLanguageModeDelegate { - func treeSitterLanguageMode(_ languageMode: TreeSitterInternalLanguageMode, bytesAt byteIndex: ByteCount) -> TreeSitterTextProviderResult? { - guard byteIndex.value >= 0 && byteIndex < stringView.string.byteCount else { - return nil - } - let targetByteCount: ByteCount = 4 * 1_024 - let endByte = min(byteIndex + targetByteCount, stringView.string.byteCount) - let byteRange = ByteRange(from: byteIndex, to: endByte) - if let result = stringView.bytes(in: byteRange) { - return TreeSitterTextProviderResult(bytes: result.bytes, length: UInt32(result.length.value)) - } else { - return nil - } - } -} - -// 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? { - languageMode.createLineSyntaxHighlighter() - } - - func lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ lineController: LineController) { - setNeedsLayout() - layoutManager.setNeedsLayout() - } -} - -// MARK: - LayoutManagerDelegate -extension TextInputView: LayoutManagerDelegate { - func layoutManager(_ layoutManager: LayoutManager, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) { - delegate?.textInputView(self, didProposeContentOffsetAdjustment: contentOffsetAdjustment) - } -} - -// MARK: - IndentControllerDelegate -extension TextInputView: IndentControllerDelegate { - func indentController(_ controller: IndentController, shouldInsert text: String, in range: NSRange) { - replaceText(in: range, with: text) - } - - func indentController(_ controller: IndentController, shouldSelect range: NSRange) { - inputDelegate?.selectionWillChange(self) - selectedRange = range - inputDelegate?.selectionDidChange(self) - } - - func indentControllerDidUpdateTabWidth(_ controller: IndentController) { - invalidateLines() - } -} - -// MARK: - EditMenuControllerDelegate -extension TextInputView: EditMenuControllerDelegate { - func editMenuController(_ controller: EditMenuController, caretRectAt location: Int) -> CGRect { - caretRectService.caretRect(at: location, allowMovingCaretToNextLineFragment: false) - } - - func editMenuControllerShouldReplaceText(_ controller: EditMenuController) { - replaceTextInSelectedHighlightedRange() - } - - func editMenuController(_ controller: EditMenuController, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - delegate?.textInputView(self, canReplaceTextIn: highlightedRange) ?? false - } - - func editMenuController(_ controller: EditMenuController, highlightedRangeFor range: NSRange) -> HighlightedRange? { - highlightedRange(for: range) - } - - func selectedRange(for controller: EditMenuController) -> NSRange? { - selectedRange - } -} diff --git a/Sources/Runestone/TextView/Core/TextView.swift b/Sources/Runestone/TextView/Core/TextView.swift deleted file mode 100644 index 69bfdcfd4..000000000 --- a/Sources/Runestone/TextView/Core/TextView.swift +++ /dev/null @@ -1,1507 +0,0 @@ -// swiftlint:disable file_length type_body_length -import CoreText -import UIKit - -/// A type similiar to UITextView with features commonly found in code editors. -/// -/// `TextView` is a performant implementation of a text view with features such as showing line numbers, searching for text and replacing results, syntax highlighting, showing invisible characters and more. -/// -/// The type does not subclass `UITextView` but its interface is kept close to `UITextView`. -/// -/// When initially configuring the `TextView` with a theme, a language and the text to be shown, it is recommended to use the ``setState(_:addUndoAction:)`` function. -/// The function takes an instance of ``TextViewState`` as input which can be created on a background queue to avoid blocking the main queue while doing the initial parse of a text. -open class TextView: UIScrollView { - /// Delegate to receive callbacks for events triggered by the editor. - public weak var editorDelegate: TextViewDelegate? - /// Whether the text view is in a state where the contents can be edited. - public private(set) var isEditing = false { - didSet { - if isEditing != oldValue { - textInputView.isEditing = isEditing - } - } - } - /// The text that the text view displays. - public var text: String { - get { - textInputView.string as String - } - set { - textInputView.string = newValue as NSString - contentSize = preferredContentSize - } - } - /// A Boolean value that indicates whether the text view is editable. - public var isEditable = true { - didSet { - if isEditable != oldValue && !isEditable && isEditing { - resignFirstResponder() - textInputViewDidEndEditing(textInputView) - } - } - } - /// A Boolean value that indicates whether the text view is selectable. - public var isSelectable = true { - didSet { - if isSelectable != oldValue { - textInputView.isUserInteractionEnabled = isSelectable - if !isSelectable && isEditing { - resignFirstResponder() - textInputView.clearSelection() - textInputViewDidEndEditing(textInputView) - } - } - } - } - /// Colors and fonts to be used by the editor. - public var theme: Theme { - get { - textInputView.theme - } - set { - textInputView.theme = newValue - } - } - /// The autocorrection style for the text view. - public var autocorrectionType: UITextAutocorrectionType { - get { - textInputView.autocorrectionType - } - set { - textInputView.autocorrectionType = newValue - } - } - /// The autocapitalization style for the text view. - public var autocapitalizationType: UITextAutocapitalizationType { - get { - textInputView.autocapitalizationType - } - set { - textInputView.autocapitalizationType = newValue - } - } - /// The spell-checking style for the text view. - public var smartQuotesType: UITextSmartQuotesType { - get { - textInputView.smartQuotesType - } - set { - textInputView.smartQuotesType = newValue - } - } - /// The configuration state for smart dashes. - public var smartDashesType: UITextSmartDashesType { - get { - textInputView.smartDashesType - } - set { - textInputView.smartDashesType = newValue - } - } - /// The configuration state for the smart insertion and deletion of space characters. - public var smartInsertDeleteType: UITextSmartInsertDeleteType { - get { - textInputView.smartInsertDeleteType - } - set { - textInputView.smartInsertDeleteType = newValue - } - } - /// The spell-checking style for the text object. - public var spellCheckingType: UITextSpellCheckingType { - get { - textInputView.spellCheckingType - } - set { - textInputView.spellCheckingType = newValue - } - } - /// The keyboard type for the text view. - public var keyboardType: UIKeyboardType { - get { - textInputView.keyboardType - } - set { - textInputView.keyboardType = newValue - } - } - /// The appearance style of the keyboard for the text view. - public var keyboardAppearance: UIKeyboardAppearance { - get { - textInputView.keyboardAppearance - } - set { - textInputView.keyboardAppearance = newValue - } - } - /// The display of the return key. - public var returnKeyType: UIReturnKeyType { - get { - textInputView.returnKeyType - } - set { - textInputView.returnKeyType = newValue - } - } - /// Returns the undo manager used by the text view. - override public var undoManager: UndoManager? { - textInputView.undoManager - } - /// The color of the insertion point. This can be used to control the color of the caret. - public var insertionPointColor: UIColor { - get { - textInputView.insertionPointColor - } - set { - textInputView.insertionPointColor = newValue - } - } - /// The color of the selection bar. It is most common to set this to the same color as the color used for the insertion point. - public var selectionBarColor: UIColor { - get { - textInputView.selectionBarColor - } - set { - textInputView.selectionBarColor = newValue - } - } - /// The color of the selection highlight. It is most common to set this to the same color as the color used for the insertion point. - public var selectionHighlightColor: UIColor { - get { - textInputView.selectionHighlightColor - } - set { - textInputView.selectionHighlightColor = newValue - } - } - /// The current selection range of the text view. - public var selectedRange: NSRange { - get { - if let selectedRange = textInputView.selectedRange { - return selectedRange - } else { - // UITextView returns the end of the document for the selectedRange by default. - return NSRange(location: textInputView.string.length, length: 0) - } - } - set { - textInputView.selectedTextRange = IndexedRange(newValue) - } - } - /// The current selection range of the text view as a UITextRange. - public var selectedTextRange: UITextRange? { - get { - textInputView.selectedTextRange - } - set { - textInputView.selectedTextRange = newValue - } - } - #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 { - if isInputAccessoryViewEnabled { - return _inputAccessoryView - } else { - return nil - } - } - set { - _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 - /// Returns a Boolean value indicating whether this object can become the first responder. - override public var canBecomeFirstResponder: Bool { - !textInputView.isFirstResponder && isEditable - } - /// The text view's background color. - override public var backgroundColor: UIColor? { - get { - textInputView.backgroundColor - } - set { - super.backgroundColor = newValue - textInputView.backgroundColor = newValue - } - } - /// The point at which the origin of the content view is offset from the origin of the scroll view. - override public var contentOffset: CGPoint { - didSet { - if contentOffset != oldValue { - textInputView.viewport = CGRect(origin: contentOffset, size: frame.size) - } - } - } - /// Character pairs are used by the editor to automatically insert a trailing character when the user types the leading character. - /// - /// Common usages of this includes the \" character to surround strings and { } to surround a scope. - public var characterPairs: [CharacterPair] { - get { - textInputView.characterPairs - } - set { - textInputView.characterPairs = newValue - } - } - /// Determines what should happen to the trailing component of a character pair when deleting the leading component. Defaults to `disabled` meaning that nothing will happen. - public var characterPairTrailingComponentDeletionMode: CharacterPairTrailingComponentDeletionMode { - get { - textInputView.characterPairTrailingComponentDeletionMode - } - set { - textInputView.characterPairTrailingComponentDeletionMode = newValue - } - } - /// Enable to show line numbers in the gutter. - public var showLineNumbers: Bool { - get { - textInputView.showLineNumbers - } - set { - textInputView.showLineNumbers = newValue - } - } - /// Enable to show highlight the selected lines. The selection is only shown in the gutter when multiple lines are selected. - public var lineSelectionDisplayType: LineSelectionDisplayType { - get { - textInputView.lineSelectionDisplayType - } - set { - textInputView.lineSelectionDisplayType = newValue - } - } - /// The text view renders invisible tabs when enabled. The `tabsSymbol` is used to render tabs. - public var showTabs: Bool { - get { - textInputView.showTabs - } - set { - textInputView.showTabs = newValue - } - } - /// The text view renders invisible spaces when enabled. - /// - /// he `spaceSymbol` is used to render spaces. - public var showSpaces: Bool { - get { - textInputView.showSpaces - } - set { - textInputView.showSpaces = newValue - } - } - /// The text view renders invisible spaces when enabled. - /// - /// The `nonBreakingSpaceSymbol` is used to render spaces. - public var showNonBreakingSpaces: Bool { - get { - textInputView.showNonBreakingSpaces - } - set { - textInputView.showNonBreakingSpaces = newValue - } - } - /// The text view renders invisible line breaks when enabled. - /// - /// The `lineBreakSymbol` is used to render line breaks. - public var showLineBreaks: Bool { - get { - textInputView.showLineBreaks - } - set { - textInputView.showLineBreaks = newValue - } - } - /// The text view renders invisible soft line breaks when enabled. - /// - /// The `softLineBreakSymbol` is used to render line breaks. These line breaks are typically represented by the U+2028 unicode character. Runestone does not provide any key commands for inserting these but supports rendering them. - public var showSoftLineBreaks: Bool { - get { - textInputView.showSoftLineBreaks - } - set { - textInputView.showSoftLineBreaks = newValue - } - } - /// Symbol used to display tabs. - /// - /// The value is only used when invisible tab characters is enabled. The default is ▸. - /// - /// Common characters for this symbol include ▸, ⇥, ➜, ➞, and ❯. - public var tabSymbol: String { - get { - textInputView.tabSymbol - } - set { - textInputView.tabSymbol = newValue - } - } - /// Symbol used to display spaces. - /// - /// The value is only used when showing invisible space characters is enabled. The default is ·. - /// - /// Common characters for this symbol include ·, •, and _. - public var spaceSymbol: String { - get { - textInputView.spaceSymbol - } - set { - textInputView.spaceSymbol = newValue - } - } - /// Symbol used to display non-breaking spaces. - /// - /// The value is only used when showing invisible space characters is enabled. The default is ·. - /// - /// Common characters for this symbol include ·, •, and _. - public var nonBreakingSpaceSymbol: String { - get { - textInputView.nonBreakingSpaceSymbol - } - set { - textInputView.nonBreakingSpaceSymbol = newValue - } - } - /// Symbol used to display line break. - /// - /// The value is only used when showing invisible line break characters is enabled. The default is ¬. - /// - /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. - public var lineBreakSymbol: String { - get { - textInputView.lineBreakSymbol - } - set { - textInputView.lineBreakSymbol = newValue - } - } - /// Symbol used to display soft line breaks. - /// - /// The value is only used when showing invisible soft line break characters is enabled. The default is ¬. - /// - /// Common characters for this symbol include ¬, ↵, ↲, ⤶, and ¶. - public var softLineBreakSymbol: String { - get { - textInputView.softLineBreakSymbol - } - set { - textInputView.softLineBreakSymbol = newValue - } - } - /// The strategy used when indenting text. - public var indentStrategy: IndentStrategy { - get { - textInputView.indentStrategy - } - set { - textInputView.indentStrategy = newValue - } - } - /// The amount of padding before the line numbers inside the gutter. - public var gutterLeadingPadding: CGFloat { - get { - textInputView.gutterLeadingPadding - } - set { - textInputView.gutterLeadingPadding = newValue - } - } - /// The amount of padding after the line numbers inside the gutter. - public var gutterTrailingPadding: CGFloat { - get { - textInputView.gutterTrailingPadding - } - set { - textInputView.gutterTrailingPadding = newValue - } - } - /// The minimum amount of characters to use for width calculation inside the gutter. - public var gutterMinimumCharacterCount: Int { - get { - textInputView.gutterMinimumCharacterCount - } - set { - textInputView.gutterMinimumCharacterCount = newValue - } - } - /// The amount of spacing surrounding the lines. - public var textContainerInset: UIEdgeInsets { - get { - textInputView.textContainerInset - } - set { - textInputView.textContainerInset = newValue - } - } - /// When line wrapping is disabled, users can scroll the text view horizontally to see the entire line. - /// - /// Line wrapping is enabled by default. - public var isLineWrappingEnabled: Bool { - get { - textInputView.isLineWrappingEnabled - } - set { - textInputView.isLineWrappingEnabled = newValue - } - } - /// Line break mode for text view. The default value is .byWordWrapping meaning that wrapping occurs on word boundaries. - public var lineBreakMode: LineBreakMode { - get { - textInputView.lineBreakMode - } - set { - textInputView.lineBreakMode = newValue - } - } - /// Width of the gutter. - public var gutterWidth: CGFloat { - textInputView.gutterWidth - } - /// The line-height is multiplied with the value. - public var lineHeightMultiplier: CGFloat { - get { - textInputView.lineHeightMultiplier - } - set { - textInputView.lineHeightMultiplier = newValue - } - } - /// The number of points by which to adjust kern. The default value is 0 meaning that kerning is disabled. - public var kern: CGFloat { - get { - textInputView.kern - } - set { - textInputView.kern = newValue - } - } - /// The text view shows a page guide when enabled. Use `pageGuideColumn` to specify the location of the page guide. - public var showPageGuide: Bool { - get { - textInputView.showPageGuide - } - set { - textInputView.showPageGuide = newValue - } - } - /// Specifies the location of the page guide. Use `showPageGuide` to specify if the page guide should be shown. - public var pageGuideColumn: Int { - get { - textInputView.pageGuideColumn - } - set { - textInputView.pageGuideColumn = newValue - } - } - /// Automatically scrolls the text view to show the caret when typing or moving the caret. - public var isAutomaticScrollEnabled = true - /// Amount of overscroll to add in the vertical direction. - /// - /// The overscroll is a factor of the scrollable area height and will not take into account any insets. 0 means no overscroll and 1 means an amount equal to the height of the text view. Detaults to 0. - public var verticalOverscrollFactor: CGFloat = 0 { - didSet { - if horizontalOverscrollFactor != oldValue { - hasPendingContentSizeUpdate = true - handleContentSizeUpdateIfNeeded() - } - } - } - /// Amount of overscroll to add in the horizontal direction. - /// - /// The overscroll is a factor of the scrollable area height and will not take into account any insets or the width of the gutter. 0 means no overscroll and 1 means an amount equal to the width of the text view. Detaults to 0. - public var horizontalOverscrollFactor: CGFloat = 0 { - didSet { - if horizontalOverscrollFactor != oldValue { - hasPendingContentSizeUpdate = true - handleContentSizeUpdateIfNeeded() - } - } - } - /// Ranges in the text to be highlighted. The color defined by the background will be drawen behind the text. - public var highlightedRanges: [HighlightedRange] { - get { - textInputView.highlightedRanges - } - set { - textInputView.highlightedRanges = newValue - highlightNavigationController.highlightedRanges = newValue - } - } - /// Wheter the text view should loop when navigating through highlighted ranges using `selectPreviousHighlightedRange` or `selectNextHighlightedRange` on the text view. - public var highlightedRangeLoopingMode: HighlightedRangeLoopingMode { - get { - if highlightNavigationController.loopRanges { - return .enabled - } else { - return .disabled - } - } - set { - switch newValue { - case .enabled: - highlightNavigationController.loopRanges = true - case .disabled: - highlightNavigationController.loopRanges = false - } - } - } - /// Line endings to use when inserting a line break. - /// - /// The value only affects new line breaks inserted in the text view and changing this value does not change the line endings of the text in the text view. Defaults to Unix (LF). - /// - /// The TextView will only update the line endings when text is modified through an external event, such as when the user typing on the keyboard, when the user is replacing selected text, and when pasting text into the text view. In all other cases, you should make sure that the text provided to the text view uses the desired line endings. This includes when calling ``TextView/setState(_:addUndoAction:)`` and ``TextView/replaceText(in:)``. - public var lineEndings: LineEnding { - get { - textInputView.lineEndings - } - set { - textInputView.lineEndings = newValue - } - } - /// 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 - /// 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. - @available(iOS 16, *) - public var isFindInteractionEnabled: Bool { - get { - textSearchingHelper.isFindInteractionEnabled - } - set { - textSearchingHelper.isFindInteractionEnabled = newValue - } - } - /// The text view’s built-in find interaction. - /// - /// Set to true to enable the text view's built-in find interaction. This method returns nil when the interaction isn't enabled. - /// - /// Call [`presentFindNavigator(showingReplace:)`](https://developer.apple.com/documentation/uikit/uifindinteraction/3975832-presentfindnavigator) on the UIFindInteraction object to invoke the find interaction and display the find panel. - @available(iOS 16, *) - public var findInteraction: UIFindInteraction? { - textSearchingHelper.findInteraction - } - - private let textInputView: TextInputView - private let editableTextInteraction = UITextInteraction(for: .editable) - private let nonEditableTextInteraction = UITextInteraction(for: .nonEditable) - @available(iOS 16.0, *) - private var editMenuInteraction: UIEditMenuInteraction? { - _editMenuInteraction as? UIEditMenuInteraction - } - private var _editMenuInteraction: Any? - private let tapGestureRecognizer = QuickTapGestureRecognizer() - private var _inputAccessoryView: UIView? - private var isPerformingNonEditableTextInteraction = false - private var delegateAllowsEditingToBegin: Bool { - guard isEditable else { - return false - } - if let editorDelegate = editorDelegate { - return editorDelegate.textViewShouldBeginEditing(self) - } else { - return true - } - } - private var shouldEndEditing: Bool { - if let editorDelegate = editorDelegate { - return editorDelegate.textViewShouldEndEditing(self) - } else { - return true - } - } - private var hasPendingContentSizeUpdate = false - private var isInputAccessoryViewEnabled = false - private let keyboardObserver = KeyboardObserver() - private let highlightNavigationController = HighlightNavigationController() - private var textSearchingHelper = UITextSearchingHelper() - // Store a reference to instances of the private type UITextRangeAdjustmentGestureRecognizer in order to track adjustments - // to the selected text range and scroll the text view when the handles approach the bottom. - // The approach is based on the one described in Steve Shephard's blog post "Adventures with UITextInteraction". - // https://steveshepard.com/blog/adventures-with-uitextinteraction/ - private var textRangeAdjustmentGestureRecognizers: Set = [] - private var previousSelectedRangeDuringGestureHandling: NSRange? - private var preferredContentSize: CGSize { - let horizontalOverscrollLength = max(frame.width * horizontalOverscrollFactor, 0) - let verticalOverscrollLength = max(frame.height * verticalOverscrollFactor, 0) - let baseContentSize = textInputView.contentSize - let width = isLineWrappingEnabled ? baseContentSize.width : baseContentSize.width + horizontalOverscrollLength - let height = baseContentSize.height + verticalOverscrollLength - return CGSize(width: width, height: height) - } - - /// Create a new text view. - /// - Parameter frame: The frame rectangle of the text view. - override public init(frame: CGRect) { - textInputView = TextInputView(theme: DefaultTheme()) - super.init(frame: frame) - backgroundColor = .white - textInputView.delegate = self - textInputView.gutterParentView = self - editableTextInteraction.textInput = textInputView - nonEditableTextInteraction.textInput = textInputView - editableTextInteraction.delegate = self - nonEditableTextInteraction.delegate = self - addSubview(textInputView) - tapGestureRecognizer.delegate = self - tapGestureRecognizer.addTarget(self, action: #selector(handleTap(_:))) - addGestureRecognizer(tapGestureRecognizer) - installNonEditableInteraction() - keyboardObserver.delegate = self - highlightNavigationController.delegate = self - textSearchingHelper.textView = self - } - - /// The initializer has not been implemented. - /// - Parameter coder: Not used. - public required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - /// Lays out subviews. - override open func layoutSubviews() { - super.layoutSubviews() - handleContentSizeUpdateIfNeeded() - textInputView.scrollViewWidth = frame.width - textInputView.frame = CGRect(x: 0, y: 0, width: max(contentSize.width, frame.width), height: max(contentSize.height, frame.height)) - textInputView.viewport = CGRect(origin: contentOffset, size: frame.size) - bringSubviewToFront(textInputView.gutterContainerView) - } - - /// Called when the safe area of the view changes. - override open func safeAreaInsetsDidChange() { - super.safeAreaInsetsDidChange() - textInputView.scrollViewSafeAreaInsets = safeAreaInsets - contentSize = preferredContentSize - layoutIfNeeded() - } - - /// Asks UIKit to make this object the first responder in its window. - @discardableResult - override open func becomeFirstResponder() -> Bool { - if !isEditing && delegateAllowsEditingToBegin { - _ = textInputView.resignFirstResponder() - _ = textInputView.becomeFirstResponder() - return true - } else { - return false - } - } - - /// Notifies this object that it has been asked to relinquish its status as first responder in its window. - @discardableResult - override open func resignFirstResponder() -> Bool { - if isEditing && shouldEndEditing { - return textInputView.resignFirstResponder() - } else { - return false - } - } - - /// Updates the custom input and accessory views when the object is the first responder. - override open func reloadInputViews() { - textInputView.reloadInputViews() - } - - /// Sets the current _state_ of the editor. The state contains the text to be displayed by the editor and - /// various additional information about the text that the editor needs to show the text. - /// - /// It is safe to create an instance of TextViewState in the background, and as such it can be - /// created before presenting the editor to the user, e.g. when opening the document from an instance of - /// UIDocumentBrowserViewController. - /// - /// This is the preferred way to initially set the text, language and theme on the TextView. - /// - Parameter state: The new state to be used by the editor. - /// - Parameter addUndoAction: Whether the state change can be undone. Defaults to false. - public func setState(_ state: TextViewState, addUndoAction: Bool = false) { - textInputView.setState(state, addUndoAction: addUndoAction) - contentSize = preferredContentSize - } - - /// Returns the row and column at the specified location in the text. - /// Common usages of this includes showing the line and column that the caret is currently located at. - /// - Parameter location: The location is relative to the first index in the string. - /// - Returns: The text location if the input location could be found in the string, otherwise nil. - public func textLocation(at location: Int) -> TextLocation? { - if let linePosition = textInputView.linePosition(at: location) { - return TextLocation(linePosition) - } else { - return nil - } - } - - /// Returns the character location at the specified row and column. - /// - Parameter textLocation: The row and column in the text. - /// - Returns: The location if the input row and column could be found in the text, otherwise nil. - public func location(at textLocation: TextLocation) -> Int? { - let lineIndex = textLocation.lineNumber - guard lineIndex >= 0 && lineIndex < textInputView.lineManager.lineCount else { - return nil - } - let line = textInputView.lineManager.line(atRow: lineIndex) - guard textLocation.column >= 0 && textLocation.column <= line.data.totalLength else { - return nil - } - return line.location + textLocation.column - } - - /// Sets the language mode on a background thread. - /// - /// - Parameters: - /// - languageMode: The new language mode to be used by the editor. - /// - completion: Called when the content have been parsed or when parsing fails. - public func setLanguageMode(_ languageMode: LanguageMode, completion: ((Bool) -> Void)? = nil) { - textInputView.setLanguageMode(languageMode, completion: completion) - } - - /// Inserts text at the location of the caret or, if no selection or caret is present, at the end of the text. - /// - Parameter text: A string to insert. - open func insertText(_ text: String) { - textInputView.inputDelegate?.selectionWillChange(textInputView) - textInputView.insertText(text) - textInputView.inputDelegate?.selectionDidChange(textInputView) - } - - /// Replaces the text that is in the specified range. - /// - Parameters: - /// - range: A range of text in the document. - /// - text: A string to replace the text in range. - open func replace(_ range: UITextRange, withText text: String) { - textInputView.inputDelegate?.selectionWillChange(textInputView) - textInputView.replace(range, withText: text) - textInputView.inputDelegate?.selectionDidChange(textInputView) - } - - /// Replaces the text that is in the specified range. - /// - Parameters: - /// - range: A range of text in the document. - /// - text: A string to replace the text in range. - public func replace(_ range: NSRange, withText text: String) { - textInputView.inputDelegate?.selectionWillChange(textInputView) - let indexedRange = IndexedRange(range) - textInputView.replace(indexedRange, withText: text) - textInputView.inputDelegate?.selectionDidChange(textInputView) - } - - /// Replaces the text in the specified matches. - /// - Parameters: - /// - batchReplaceSet: Set of ranges to replace with a text. - public func replaceText(in batchReplaceSet: BatchReplaceSet) { - textInputView.replaceText(in: batchReplaceSet) - } - - /// Deletes a character from the displayed text. - public func deleteBackward() { - textInputView.deleteBackward() - } - - /// Returns the text in the specified range. - /// - Parameter range: A range of text in the document. - /// - Returns: The substring that falls within the specified range. - public func text(in range: NSRange) -> String? { - textInputView.text(in: range) - } - - /// Returns the syntax node at the specified location in the document. - /// - /// This can be used with character pairs to determine if a pair should be inserted or not. - /// For example, a character pair consisting of two quotes (") to surround a string, should probably not be - /// inserted when the quote is typed while the caret is already inside a string. - /// - /// This requires a language to be set on the editor. - /// - Parameter location: A location in the document. - /// - Returns: The syntax node at the location. - public func syntaxNode(at location: Int) -> SyntaxNode? { - textInputView.syntaxNode(at: location) - } - - /// Checks if the specified locations is within the indentation of the line. - /// - /// - Parameter location: A location in the document. - /// - Returns: True if the location is within the indentation of the line, otherwise false. - public func isIndentation(at location: Int) -> Bool { - textInputView.isIndentation(at: location) - } - - /// Decreases the indentation level of the selected lines. - public func shiftLeft() { - textInputView.shiftLeft() - } - - /// Increases the indentation level of the selected lines. - public func shiftRight() { - textInputView.shiftRight() - } - - /// Moves the selected lines up by one line. - /// - /// Calling this function has no effect when the selected lines include the first line in the text view. - public func moveSelectedLinesUp() { - textInputView.moveSelectedLinesUp() - } - - /// Moves the selected lines down by one line. - /// - /// Calling this function has no effect when the selected lines include the last line in the text view. - public func moveSelectedLinesDown() { - textInputView.moveSelectedLinesDown() - } - - /// Attempts to detect the indent strategy used in the document. This may return an unknown strategy even - /// when the document contains indentation. - public func detectIndentStrategy() -> DetectedIndentStrategy { - textInputView.detectIndentStrategy() - } - - /// Go to the beginning of the line at the specified index. - /// - /// - Parameter lineIndex: Index of line to navigate to. - /// - Parameter selection: The placement of the caret on the line. - /// - Returns: True if the text view could navigate to the specified line index, otherwise false. - @discardableResult - public func goToLine(_ lineIndex: Int, select selection: GoToLineSelection = .beginning) -> Bool { - guard lineIndex >= 0 && lineIndex < textInputView.lineManager.lineCount else { - return false - } - // I'm not exactly sure why this is necessary but if the text view is the first responder as we jump - // to the line and we don't resign the first responder first, the caret will disappear after we have - // jumped to the specified line. - resignFirstResponder() - becomeFirstResponder() - let line = textInputView.lineManager.line(atRow: lineIndex) - textInputView.layoutLines(toLocation: line.location) - scrollLocationToVisible(line.location) - layoutIfNeeded() - switch selection { - case .beginning: - textInputView.selectedRange = NSRange(location: line.location, length: 0) - case .end: - textInputView.selectedRange = NSRange(location: line.data.length, length: line.data.length) - case .line: - textInputView.selectedRange = NSRange(location: line.location, length: line.data.length) - } - return true - } - - /// Search for the specified query. - /// - /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:)``. - /// - /// ```swift - /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) - /// let results = textView.search(for: query) - /// ``` - /// - /// - Parameter query: Query to find matches for. - /// - Returns: Results matching the query. - public func search(for query: SearchQuery) -> [SearchResult] { - let searchController = SearchController(stringView: textInputView.stringView) - searchController.delegate = self - return searchController.search(for: query) - } - - /// Search for the specified query and return results that take a replacement string into account. - /// - /// When searching for a regular expression this function will perform pattern matching and take the matched groups into account in the returned results. - /// - /// The code below shows how a ``SearchQuery`` can be constructed and passed to ``search(for:replacingMatchesWith:)`` and how the returned search results can be used to perform a replace operation. - /// - /// ```swift - /// let query = SearchQuery(text: "foo", matchMethod: .contains, isCaseSensitive: false) - /// let results = textView.search(for: query, replacingMatchesWith: "bar") - /// let replacements = results.map { BatchReplaceSet.Replacement(range: $0.range, text: $0.replacementText) } - /// let batchReplaceSet = BatchReplaceSet(replacements: replacements) - /// textView.replaceText(in: batchReplaceSet) - /// ``` - /// - /// - Parameters: - /// - query: Query to find matches for. - /// - replacementString: String to replace matches with. Can refer to groups in a regular expression using $0, $1, $2 etc. - /// - Returns: Results matching the query. - public func search(for query: SearchQuery, replacingMatchesWith replacementString: String) -> [SearchReplaceResult] { - let searchController = SearchController(stringView: textInputView.stringView) - searchController.delegate = self - return searchController.search(for: query, replacingMatchesWith: replacementString) - } - - /// Returns a peek into the text view's underlying attributed string. - /// - Parameter range: Range of text to include in text view. The returned result may span a larger range than the one specified. - /// - Returns: Text preview containing the specified range. - public func textPreview(containing range: NSRange) -> TextPreview? { - textInputView.textPreview(containing: range) - } - - /// Selects a highlighted range behind the selected range if possible. - public func selectPreviousHighlightedRange() { - highlightNavigationController.selectPreviousRange() - } - - /// Selects a highlighted range after the selected range if possible. - public func selectNextHighlightedRange() { - highlightNavigationController.selectNextRange() - } - - /// Selects the highlighed range at the specified index. - /// - Parameter index: Index of highlighted range to select. - public func selectHighlightedRange(at index: Int) { - highlightNavigationController.selectRange(at: index) - } - - /// Synchronously displays the visible lines. This can be used to immediately update the visible lines after setting the theme. Use with caution as this redisplaying the visible lines can be a costly operation. - public func redisplayVisibleLines() { - textInputView.redisplayVisibleLines() - } -} - -// MARK: - UITextInput -extension TextView { - /// The range of currently marked text in a document. - public var markedTextRange: UITextRange? { - textInputView.markedTextRange - } - - /// The text position for the beginning of a document. - public var beginningOfDocument: UITextPosition { - textInputView.beginningOfDocument - } - - /// The text position for the end of a document. - public var endOfDocument: UITextPosition { - textInputView.endOfDocument - } - - /// Returns the range between two text positions. - /// - Parameters: - /// - fromPosition: An object that represents a location in a document. - /// - toPosition: An object that represents another location in a document. - /// - Returns: An object that represents the range between fromPosition and toPosition. - public func textRange(from fromPosition: UITextPosition, to toPosition: UITextPosition) -> UITextRange? { - textInputView.textRange(from: fromPosition, to: toPosition) - } - - /// Returns the text position at a specified offset from another text position. - /// - Parameters: - /// - position: A custom UITextPosition object that represents a location in a document. - /// - offset: A character offset from position. It can be a positive or negative value. - /// - Returns: A custom UITextPosition object that represents the location in a document that is at the specified offset from position. Returns nil if the computed text position is less than 0 or greater than the length of the backing string. - public func position(from position: UITextPosition, offset: Int) -> UITextPosition? { - textInputView.position(from: position, offset: offset) - } - - /// Returns the text position at a specified offset in a specified direction from another text position. - /// - Parameters: - /// - position: A custom UITextPosition object that represents a location in a document. - /// - direction: A UITextLayoutDirection constant that represents the direction of the offset from position. - /// - offset: A character offset from position. - /// - Returns: Returns the text position at a specified offset in a specified direction from another text position. Returns nil if the computed text position is less than 0 or greater than the length of the backing string. - public func position(from position: UITextPosition, in direction: UITextLayoutDirection, offset: Int) -> UITextPosition? { - textInputView.position(from: position, in: direction, offset: offset) - } - - /// Returns how one text position compares to another text position. - /// - Parameters: - /// - position: A custom object that represents a location within a document. - /// - other: A custom object that represents another location within a document. - /// - Returns: A value that indicates whether the two text positions are identical or whether one is before the other. - public func compare(_ position: UITextPosition, to other: UITextPosition) -> ComparisonResult { - textInputView.compare(position, to: other) - } - - /// Returns the number of UTF-16 characters between one text position and another text position. - /// - Parameters: - /// - from: A custom object that represents a location within a document. - /// - toPosition: A custom object that represents another location within document. - /// - Returns: The number of UTF-16 characters between fromPosition and toPosition. - public func offset(from: UITextPosition, to toPosition: UITextPosition) -> Int { - textInputView.offset(from: from, to: toPosition) - } - - /// An input tokenizer that provides information about the granularity of text units. - public var tokenizer: UITextInputTokenizer { - textInputView.tokenizer - } - - /// Returns the text position that is at the farthest extent in a specified layout direction within a range of text. - /// - Parameters: - /// - range: A text-range object that demarcates a range of text in a document. - /// - direction: A constant that indicates a direction of layout (right, left, up, down). - /// - Returns: A text-position object that identifies a location in the visible text. - public func position(within range: UITextRange, farthestIn direction: UITextLayoutDirection) -> UITextPosition? { - textInputView.position(within: range, farthestIn: direction) - } - - /// Returns a text range from a specified text position to its farthest extent in a certain direction of layout. - /// - Parameters: - /// - position: A text-position object that identifies a location in a document. - /// - direction: A constant that indicates a direction of layout (right, left, up, down). - /// - Returns: A text-range object that represents the distance from position to the farthest extent in direction. - public func characterRange(byExtending position: UITextPosition, in direction: UITextLayoutDirection) -> UITextRange? { - textInputView.characterRange(byExtending: position, in: direction) - } - - /// Returns the first rectangle that encloses a range of text in a document. - /// - Parameter range: An object that represents a range of text in a document. - /// - Returns: The first rectangle in a range of text. You might use this rectangle to draw a correction rectangle. The “first” in the name refers the rectangle enclosing the first line when the range encompasses multiple lines of text. - public func firstRect(for range: UITextRange) -> CGRect { - textInputView.firstRect(for: range) - } - - /// Returns a rectangle to draw the caret at a specified insertion point. - /// - Parameter position: An object that identifies a location in a text input area. - /// - Returns: A rectangle that defines the area for drawing the caret. - public func caretRect(for position: UITextPosition) -> CGRect { - textInputView.caretRect(for: position) - } - - /// Returns an array of selection rects corresponding to the range of text. - /// - Parameter range: An object representing a range in a document’s text. - /// - Returns: An array of UITextSelectionRect objects that encompass the selection. - public func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { - textInputView.selectionRects(for: range) - } - - /// Returns the position in a document that is closest to a specified point. - /// - Parameter point: A point in the view that is drawing a document’s text. - /// - Returns: An object locating a position in a document that is closest to point. - public func closestPosition(to point: CGPoint) -> UITextPosition? { - textInputView.closestPosition(to: point) - } - - /// Returns the position in a document that is closest to a specified point in a specified range. - /// - Parameters: - /// - point: A point in the view that is drawing a document’s text. - /// - range: An object representing a range in a document’s text. - /// - Returns: An object representing the character position in range that is closest to point. - public func closestPosition(to point: CGPoint, within range: UITextRange) -> UITextPosition? { - textInputView.closestPosition(to: point, within: range) - } - - /// Returns the character or range of characters that is at a specified point in a document. - /// - Parameter point: A point in the view that is drawing a document’s text. - /// - Returns: An object representing a range that encloses a character (or characters) at point. - public func characterRange(at point: CGPoint) -> UITextRange? { - textInputView.characterRange(at: point) - } - - /// Returns the text in the specified range. - /// - Parameter range: A range of text in a document. - /// - Returns: A substring of a document that falls within the specified range. - public func text(in range: UITextRange) -> String? { - textInputView.text(in: range) - } - - /// A Boolean value that indicates whether the text-entry object has any text. - public var hasText: Bool { - textInputView.hasText - } - - /// Scrolls the text view to reveal the text in the specified range. - /// - /// The function will scroll the text view as little as possible while revealing as much as possible of the specified range. It is not guaranteed that the entire range is visible after performing the scroll. - /// - /// - Parameters: - /// - range: The range of text to scroll into view. - public func scrollRangeToVisible(_ range: NSRange) { - textInputView.layoutLines(toLocation: range.upperBound) - justScrollRangeToVisible(range) - } -} - -private extension TextView { - @objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { - guard isSelectable else { - return - } - if gestureRecognizer.state == .ended { - let point = gestureRecognizer.location(in: textInputView) - let oldSelectedRange = textInputView.selectedRange - textInputView.moveCaret(to: point) - if textInputView.selectedRange != oldSelectedRange { - layoutIfNeeded() - } - installEditableInteraction() - becomeFirstResponder() - } - } - - @objc private func handleTextRangeAdjustmentPan(_ gestureRecognizer: UIPanGestureRecognizer) { - // This function scroll the text view when the selected range is adjusted. - if gestureRecognizer.state == .began { - previousSelectedRangeDuringGestureHandling = selectedRange - } else if gestureRecognizer.state == .changed, let previousSelectedRange = previousSelectedRangeDuringGestureHandling { - if selectedRange.lowerBound != previousSelectedRange.lowerBound { - // User is adjusting the lower bound (location) of the selected range. - scrollLocationToVisible(selectedRange.lowerBound) - } else if selectedRange.upperBound != previousSelectedRange.upperBound { - // User is adjusting the upper bound (length) of the selected range. - scrollLocationToVisible(selectedRange.upperBound) - } - previousSelectedRangeDuringGestureHandling = selectedRange - } - } - - private func insertLeadingComponent(of characterPair: CharacterPair, in range: NSRange) -> Bool { - let shouldInsertCharacterPair = editorDelegate?.textView(self, shouldInsert: characterPair, in: range) ?? true - guard shouldInsertCharacterPair else { - return false - } - guard let selectedRange = textInputView.selectedRange else { - return false - } - if selectedRange.length == 0 { - textInputView.insertText(characterPair.leading + characterPair.trailing) - textInputView.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: 0) - return true - } else if let text = textInputView.text(in: selectedRange) { - let modifiedText = characterPair.leading + text + characterPair.trailing - let indexedRange = IndexedRange(selectedRange) - textInputView.replace(indexedRange, withText: modifiedText) - textInputView.selectedRange = NSRange(location: range.location + characterPair.leading.count, length: range.length) - return true - } else { - return false - } - } - - private func skipInsertingTrailingComponent(of characterPair: CharacterPair, in range: NSRange) -> Bool { - // When typing the trailing component of a character pair, e.g. ) or } and the cursor is just in front of that character, - // the delegate is asked whether the text view should skip inserting that character. If the character is skipped, - // then the caret is moved after the trailing character component. - let followingTextRange = NSRange(location: range.location + range.length, length: characterPair.trailing.count) - let followingText = textInputView.text(in: followingTextRange) - guard followingText == characterPair.trailing else { - return false - } - let shouldSkip = editorDelegate?.textView(self, shouldSkipTrailingComponentOf: characterPair, in: range) ?? true - if shouldSkip { - moveCaret(byOffset: characterPair.trailing.count) - return true - } else { - return false - } - } - - private func moveCaret(byOffset offset: Int) { - if let selectedRange = textInputView.selectedRange { - textInputView.selectedRange = NSRange(location: selectedRange.location + offset, length: 0) - } - } - - private func handleContentSizeUpdateIfNeeded() { - if hasPendingContentSizeUpdate { - // We don't want to update the content size when the scroll view is "bouncing" near the gutter, - // or at the end of a line since it causes flickering when updating the content size while scrolling. - // However, we do allow updating the content size if the text view is scrolled far enough on - // the y-axis as that means it will soon run out of text to display. - let isBouncingAtGutter = contentOffset.x < -contentInset.left - let isBouncingAtLineEnd = contentOffset.x > contentSize.width - frame.size.width + contentInset.right - let isBouncingHorizontally = isBouncingAtGutter || isBouncingAtLineEnd - let isCriticalUpdate = contentOffset.y > contentSize.height - frame.height * 1.5 - let isScrolling = isDragging || isDecelerating - if !isBouncingHorizontally || isCriticalUpdate || !isScrolling { - hasPendingContentSizeUpdate = false - let oldContentOffset = contentOffset - contentSize = preferredContentSize - contentOffset = oldContentOffset - setNeedsLayout() - } - } - } - - private func justScrollRangeToVisible(_ range: NSRange) { - let lowerBoundRect = textInputView.caretRect(at: range.lowerBound) - let upperBoundRect = range.length == 0 ? lowerBoundRect : textInputView.caretRect(at: range.upperBound) - let rectMinX = min(lowerBoundRect.minX, upperBoundRect.minX) - let rectMaxX = max(lowerBoundRect.maxX, upperBoundRect.maxX) - let rectMinY = min(lowerBoundRect.minY, upperBoundRect.minY) - let rectMaxY = max(lowerBoundRect.maxY, upperBoundRect.maxY) - let rect = CGRect(x: rectMinX, y: rectMinY, width: rectMaxX - rectMinX, height: rectMaxY - rectMinY) - contentOffset = contentOffsetForScrollingToVisibleRect(rect) - } - - private func scrollLocationToVisible(_ location: Int) { - let range = NSRange(location: location, length: 0) - justScrollRangeToVisible(range) - } - - private func installEditableInteraction() { - if editableTextInteraction.view == nil { - 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 - } - } - - private func installNonEditableInteraction() { - if nonEditableTextInteraction.view == nil { - isInputAccessoryViewEnabled = false - textInputView.removeInteraction(editableTextInteraction) - textInputView.addInteraction(nonEditableTextInteraction) - for gestureRecognizer in nonEditableTextInteraction.gesturesForFailureRequirements { - gestureRecognizer.require(toFail: tapGestureRecognizer) - } - } - } - - /// Computes a content offset to scroll to in order to reveal the specified rectangle. - /// - /// The function will return a rectangle that scrolls the text view a minimum amount while revealing as much as possible of the rectangle. It is not guaranteed that the entire rectangle can be revealed. - /// - Parameter rect: The rectangle to reveal. - /// - Returns: The content offset to scroll to. - private func contentOffsetForScrollingToVisibleRect(_ rect: CGRect) -> CGPoint { - // Create the viewport: a rectangle containing the content that is visible to the user. - var viewport = CGRect(x: contentOffset.x, y: contentOffset.y, width: frame.width, height: frame.height) - viewport.origin.y += adjustedContentInset.top - viewport.origin.x += adjustedContentInset.left + gutterWidth - viewport.size.width -= adjustedContentInset.left + adjustedContentInset.right + gutterWidth - viewport.size.height -= adjustedContentInset.top + adjustedContentInset.bottom - // Construct the best possible content offset. - var newContentOffset = contentOffset - if rect.minX < viewport.minX { - newContentOffset.x -= viewport.minX - rect.minX - } else if rect.maxX > viewport.maxX && rect.width <= viewport.width { - // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. - newContentOffset.x += rect.maxX - viewport.maxX - } else if rect.maxX > viewport.maxX { - newContentOffset.x += rect.minX - } - if rect.minY < viewport.minY { - newContentOffset.y -= viewport.minY - rect.minY - } else if rect.maxY > viewport.maxY && rect.height <= viewport.height { - // The end of the rectangle is not visible and the rect fits within the screen so we'll scroll to reveal the entire rect. - newContentOffset.y += rect.maxY - viewport.maxY - } else if rect.maxY > viewport.maxY { - newContentOffset.y += rect.minY - } - let cappedXOffset = min(max(newContentOffset.x, minimumContentOffset.x), maximumContentOffset.x) - let cappedYOffset = min(max(newContentOffset.y, minimumContentOffset.y), maximumContentOffset.y) - return CGPoint(x: cappedXOffset, y: cappedYOffset) - } -} - -// MARK: - TextInputViewDelegate -extension TextView: TextInputViewDelegate { - func textInputViewWillBeginEditing(_ view: TextInputView) { - guard isEditable else { - return - } - isEditing = !isPerformingNonEditableTextInteraction - // If a developer is programmatically calling becomeFirstresponder() then we might not have a selected range. - // We set the selectedRange instead of the selectedTextRange to avoid invoking any delegates. - if textInputView.selectedRange == nil && !isPerformingNonEditableTextInteraction { - textInputView.selectedRange = NSRange(location: 0, length: 0) - } - // Ensure selection is laid out without animation. - UIView.performWithoutAnimation { - textInputView.layoutIfNeeded() - } - // The editable interaction must be installed early in the -becomeFirstResponder() call - if !isPerformingNonEditableTextInteraction { - installEditableInteraction() - } - } - - func textInputViewDidBeginEditing(_ view: TextInputView) { - if !isPerformingNonEditableTextInteraction { - editorDelegate?.textViewDidBeginEditing(self) - } - } - - func textInputViewDidCancelBeginEditing(_ view: TextInputView) { - isEditing = false - installNonEditableInteraction() - } - - func textInputViewDidEndEditing(_ view: TextInputView) { - isEditing = false - installNonEditableInteraction() - editorDelegate?.textViewDidEndEditing(self) - } - - func textInputViewDidChange(_ view: TextInputView) { - if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { - scrollLocationToVisible(newRange.location) - } - editorDelegate?.textViewDidChange(self) - } - - func textInputViewDidChangeSelection(_ view: TextInputView) { - UIMenuController.shared.hideMenu(from: self) - highlightNavigationController.selectedRange = view.selectedRange - if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { - scrollLocationToVisible(newRange.location) - } - editorDelegate?.textViewDidChangeSelection(self) - } - - func textInputViewDidInvalidateContentSize(_ view: TextInputView) { - if contentSize != view.contentSize { - hasPendingContentSizeUpdate = true - handleContentSizeUpdateIfNeeded() - } - } - - func textInputView(_ view: TextInputView, didProposeContentOffsetAdjustment contentOffsetAdjustment: CGPoint) { - let isScrolling = isDragging || isDecelerating - if contentOffsetAdjustment != .zero && isScrolling { - contentOffset = CGPoint(x: contentOffset.x + contentOffsetAdjustment.x, y: contentOffset.y + contentOffsetAdjustment.y) - } - } - - func textInputView(_ view: TextInputView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - if textInputView.isRestoringPreviouslyDeletedText { - // UIKit is inserting text to combine characters, for example to combine two Korean characters into one, and we do not want to interfere with that. - return editorDelegate?.textView(self, shouldChangeTextIn: range, replacementText: text) ?? true - } else if let characterPair = characterPairs.first(where: { $0.trailing == text }), - skipInsertingTrailingComponent(of: characterPair, in: range) { - return false - } else if let characterPair = characterPairs.first(where: { $0.leading == text }), insertLeadingComponent(of: characterPair, in: range) { - return false - } else { - return editorDelegate?.textView(self, shouldChangeTextIn: range, replacementText: text) ?? true - } - } - - func textInputViewDidChangeGutterWidth(_ view: TextInputView) { - editorDelegate?.textViewDidChangeGutterWidth(self) - } - - func textInputViewDidBeginFloatingCursor(_ view: TextInputView) { - editorDelegate?.textViewDidBeginFloatingCursor(self) - } - - func textInputViewDidEndFloatingCursor(_ view: TextInputView) { - editorDelegate?.textViewDidEndFloatingCursor(self) - } - - func textInputViewDidUpdateMarkedRange(_ view: TextInputView) { - // There seems to be a bug in UITextInput (or UITextInteraction?) where updating the markedTextRange of a UITextInput - // will cause the caret to disappear. Removing the editable text interaction and adding it back will work around this issue. - DispatchQueue.main.async { - 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 - } - } - } - - func textInputView(_ view: TextInputView, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { - editorDelegate?.textView(self, canReplaceTextIn: highlightedRange) ?? false - } - - func textInputView(_ view: TextInputView, replaceTextIn highlightedRange: HighlightedRange) { - editorDelegate?.textView(self, replaceTextIn: highlightedRange) - } -} - -// MARK: - HighlightNavigationControllerDelegate -extension TextView: HighlightNavigationControllerDelegate { - func highlightNavigationController(_ controller: HighlightNavigationController, - shouldNavigateTo highlightNavigationRange: HighlightNavigationRange) { - let range = highlightNavigationRange.range - scrollRangeToVisible(range) - textInputView.selectedTextRange = IndexedRange(range) - _ = textInputView.becomeFirstResponder() - if showMenuAfterNavigatingToHighlightedRange { - textInputView.presentEditMenuForText(in: range) - } - switch highlightNavigationRange.loopMode { - case .previousGoesToLast: - editorDelegate?.textViewDidLoopToLastHighlightedRange(self) - case .nextGoesToFirst: - editorDelegate?.textViewDidLoopToFirstHighlightedRange(self) - case .disabled: - break - } - } -} - -// MARK: - SearchControllerDelegate -extension TextView: SearchControllerDelegate { - func searchController(_ searchController: SearchController, linePositionAt location: Int) -> LinePosition? { - textInputView.lineManager.linePosition(at: location) - } -} - -// MARK: - UIGestureRecognizerDelegate -extension TextView: UIGestureRecognizerDelegate { - override public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer === tapGestureRecognizer { - return !isEditing && !isDragging && !isDecelerating && delegateAllowsEditingToBegin - } else { - return super.gestureRecognizerShouldBegin(gestureRecognizer) - } - } - - public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, - shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - if let klass = NSClassFromString("UITextRangeAdjustmentGestureRecognizer") { - if !textRangeAdjustmentGestureRecognizers.contains(otherGestureRecognizer) && otherGestureRecognizer.isKind(of: klass) { - otherGestureRecognizer.addTarget(self, action: #selector(handleTextRangeAdjustmentPan(_:))) - textRangeAdjustmentGestureRecognizers.insert(otherGestureRecognizer) - } - } - return gestureRecognizer !== panGestureRecognizer - } -} - -// MARK: - KeyboardObserverDelegate -extension TextView: KeyboardObserverDelegate { - func keyboardObserver(_ keyboardObserver: KeyboardObserver, - keyboardWillShowWithHeight keyboardHeight: CGFloat, - animation: KeyboardObserver.Animation?) { - if isAutomaticScrollEnabled, let newRange = textInputView.selectedRange, newRange.length == 0 { - scrollRangeToVisible(newRange) - } - } -} - -// MARK: - UITextInteractionDelegate -extension TextView: UITextInteractionDelegate { - public func interactionShouldBegin(_ interaction: UITextInteraction, at point: CGPoint) -> Bool { - if interaction.textInteractionMode == .editable { - return isEditable - } else if interaction.textInteractionMode == .nonEditable { - // The private UITextLoupeInteraction and UITextNonEditableInteractionclass will end up in this case. The latter is likely created from UITextInteraction(for: .nonEditable) but we want to disable both when selection is disabled. - return isSelectable - } else { - return true - } - } - - public func interactionWillBegin(_ interaction: UITextInteraction) { - if interaction.textInteractionMode == .nonEditable { - // When long-pressing our instance of UITextInput, the UITextInteraction will make the text input first responder. - // In this case the user wants to select text in the text view but not start editing, so we set a flag that tells us - // that we should not install editable text interaction in this case. - isPerformingNonEditableTextInteraction = true - } - } - - public func interactionDidEnd(_ interaction: UITextInteraction) { - if interaction.textInteractionMode == .nonEditable { - isPerformingNonEditableTextInteraction = false - } - } -} -// swiftlint:enable type_body_length diff --git a/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift index 45e39c187..0a5a46db5 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextInputStringTokenizer.swift @@ -2,110 +2,259 @@ import UIKit final class TextInputStringTokenizer: UITextInputStringTokenizer { - var lineManager: LineManager { - get { - stringTokenizer.lineManager - } - set { - stringTokenizer.lineManager = newValue - } + 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] { + [Symbol.Character.lineFeed, Symbol.Character.carriageReturn, Symbol.Character.carriageReturnLineFeed] } - var stringView: StringView { - get { - stringTokenizer.stringView - } - set { - stringTokenizer.stringView = newValue - } + + init(textInput: UIResponder & UITextInput, stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { + self.lineManager = lineManager + self.stringView = stringView + self.lineControllerStorage = lineControllerStorage + super.init(textInput: textInput) } - private let stringTokenizer: StringTokenizer + override func isPosition(_ position: UITextPosition, atBoundary granularity: UITextGranularity, inDirection direction: UITextDirection) -> Bool { + if granularity == .line { + return isPosition(position, atLineBoundaryInDirection: direction) + } else if granularity == .paragraph { + return isPosition(position, atParagraphBoundaryInDirection: direction) + } else if granularity == .word { + return isPosition(position, atWordBoundaryInDirection: direction) + } else { + return super.isPosition(position, atBoundary: granularity, inDirection: direction) + } + } - init( - textInput: UIResponder & UITextInput, - stringView: StringView, - lineManager: LineManager, - lineControllerStorage: LineControllerStorage - ) { - self.stringTokenizer = StringTokenizer(stringView: stringView, lineManager: lineManager, lineControllerStorage: lineControllerStorage) - super.init(textInput: textInput) + 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 { + 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 isPosition( - _ position: UITextPosition, - atBoundary granularity: UITextGranularity, - inDirection direction: UITextDirection - ) -> Bool { +// MARK: - Lines +private extension TextInputStringTokenizer { + private func isPosition(_ position: UITextPosition, atLineBoundaryInDirection direction: UITextDirection) -> Bool { guard let indexedPosition = position as? IndexedPosition else { return false } - guard let boundary = TextBoundary(granularity) else { - return super.isPosition(position, atBoundary: granularity, inDirection: direction) + let location = indexedPosition.index + guard let line = lineManager.line(containingCharacterAt: location) else { + return false + } + let lineLocation = line.location + let lineLocalLocation = location - lineLocation + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + guard lineLocalLocation >= 0 && lineLocalLocation <= line.data.totalLength else { + return false + } + guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { + return false + } + if direction.isForward { + let isLastLineFragment = lineFragmentNode.index == lineController.numberOfLineFragments - 1 + if isLastLineFragment { + return location == lineLocation + lineFragmentNode.location + lineFragmentNode.value - line.data.delimiterLength + } else { + return location == lineLocation + lineFragmentNode.location + lineFragmentNode.value + } + } else { + return location == lineLocation + lineFragmentNode.location } - let direction = TextDirection(direction) - return stringTokenizer.isLocation(indexedPosition.index, atBoundary: boundary, inDirection: direction) - } - - 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? { + private func position(from position: UITextPosition, toLineBoundaryInDirection direction: UITextDirection) -> UITextPosition? { guard let indexedPosition = position as? IndexedPosition else { return nil } - guard let boundary = TextBoundary(granularity) else { - return super.position(from: position, toBoundary: granularity, inDirection: direction) + let location = indexedPosition.index + guard let line = lineManager.line(containingCharacterAt: location) else { + return nil } - let direction = TextDirection(direction) - guard let location = stringTokenizer.location(from: indexedPosition.index, toBoundary: boundary, inDirection: direction) else { + let lineController = lineControllerStorage.getOrCreateLineController(for: line) + let lineLocation = line.location + let lineLocalLocation = location - lineLocation + guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { return nil } - return IndexedPosition(index: location) + if direction.isForward { + if location == stringView.string.length { + return position + } else { + let lineFragmentRangeUpperBound = lineFragmentNode.location + lineFragmentNode.value + let preferredLocation = lineLocation + lineFragmentRangeUpperBound + 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: lineFragmentRangeUpperBound) + return IndexedPosition(index: lineLocation + lineFragmentRangeUpperBound - lastCharacterRange.length) + } + } + } else if location == 0 { + return position + } else { + return IndexedPosition(index: lineLocation + lineFragmentNode.location) + } } +} - override func rangeEnclosingPosition( - _ position: UITextPosition, - with granularity: UITextGranularity, - inDirection direction: UITextDirection - ) -> UITextRange? { - super.rangeEnclosingPosition(position, with: granularity, inDirection: direction) +// 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. + false } -} -private extension TextBoundary { - init?(_ granularity: UITextGranularity) { - switch granularity { - case .word: - self = .word - case .paragraph: - self = .paragraph - case .line: - self = .line - case .document: - self = .document - case .character, .sentence: - return nil - @unknown default: + private func position(from position: UITextPosition, toParagraphBoundaryInDirection direction: UITextDirection) -> UITextPosition? { + guard let indexedPosition = position as? IndexedPosition else { return nil } + 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 { + 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 extension TextDirection { - init(_ direction: UITextDirection) { +// MARK: - Words +private extension TextInputStringTokenizer { + private func isPosition(_ position: UITextPosition, atWordBoundaryInDirection direction: UITextDirection) -> Bool { + guard let indexedPosition = position as? IndexedPosition else { + return false + } + 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 + } + } + } + + // swiftlint:disable:next cyclomatic_complexity + private func position(from position: UITextPosition, toWordBoundaryInDirection direction: UITextDirection) -> UITextPosition? { + guard let indexedPosition = position as? IndexedPosition else { + return nil + } + didCallPositionFromPositionToWordBoundary = true + let location = indexedPosition.index + let alphanumerics = CharacterSet.alphanumerics if direction.isForward { - self = .forward + 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 { - self = .backward + 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 + } } } } @@ -117,4 +266,10 @@ private extension UITextDirection { || rawValue == UITextLayoutDirection.down.rawValue } } + +private extension CharacterSet { + func contains(_ character: Character) -> Bool { + character.unicodeScalars.allSatisfy(contains(_:)) + } +} #endif diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift index 7c6f2a1f4..440ecac9d 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -502,4 +502,26 @@ public extension TextView { /// - range: An object that represents a range of text in a document. func setBaseWritingDirection(_ writingDirection: NSWritingDirection, for range: UITextRange) {} } + +#if compiler(>=5.9) +@available(iOS 17, *) +extension UITextInput where Self: NSObject { + var sbs_textSelectionDisplayInteraction: UITextSelectionDisplayInteraction? { + let interactionAssistantKey = "int" + "ssAnoitcare".reversed() + "istant" + let selectionViewManagerKey: String = "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 + + #endif diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index 3f5eb549f..f58e59b72 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -16,7 +16,7 @@ open class TextView: UIScrollView { @objc public weak var inputDelegate: UITextInputDelegate? /// Returns a Boolean value indicating whether this object can become the first responder. override public var canBecomeFirstResponder: Bool { - !isFirstResponder && isEditable + true } /// Delegate to receive callbacks for events triggered by the editor. public weak var editorDelegate: TextViewDelegate? @@ -432,12 +432,6 @@ open class TextView: UIScrollView { textViewController.horizontalOverscrollFactor = newValue } } - /// 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? { - textViewController.lengthOfInitallyLongestLine - } /// Ranges in the text to be highlighted. The color defined by the background will be drawen behind the text. public var highlightedRanges: [HighlightedRange] { get { @@ -620,9 +614,6 @@ open class TextView: UIScrollView { /// Asks UIKit to make this object the first responder in its window. @discardableResult override open func becomeFirstResponder() -> Bool { - guard !isEditing && shouldBeginEditing else { - return false - } if canBecomeFirstResponder { willBeginEditing() } @@ -1036,6 +1027,12 @@ extension TextView { if !self.viewHierarchyContainsCaret && self.editableTextInteraction.view != nil { self.removeInteraction(self.editableTextInteraction) self.addInteraction(self.editableTextInteraction) + #if compiler(>=5.9) + if #available(iOS 17, *) { + self.sbs_textSelectionDisplayInteraction?.isActivated = true + self.sbs_textSelectionDisplayInteraction?.sbs_enableCursorBlinks() + } + #endif } } } @@ -1097,6 +1094,12 @@ private extension TextView { isInputAccessoryViewEnabled = true removeInteraction(nonEditableTextInteraction) 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). + sbs_textSelectionDisplayInteraction?.isActivated = true + } + #endif } } From b1c2f687c0542de66f7ad34b7c176816f5f7eb94 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 22 Nov 2025 16:22:14 +0200 Subject: [PATCH 225/232] Reformat Package.swift file. Remove empty file from project. --- Package.swift | 4 +++- Sources/Runestone/TextView/SearchAndReplace/Untitled.swift | 0 2 files changed, 3 insertions(+), 1 deletion(-) delete mode 100644 Sources/Runestone/TextView/SearchAndReplace/Untitled.swift diff --git a/Package.swift b/Package.swift index 894674dea..f9c367838 100644 --- a/Package.swift +++ b/Package.swift @@ -6,7 +6,9 @@ import PackageDescription let package = Package( name: "Runestone", defaultLocalization: "en", - platforms: [.iOS(.v14), .macOS(.v11)], + platforms: [ + .iOS(.v14), .macOS(.v11) + ], products: [ .library(name: "Runestone", targets: ["Runestone"]) ], diff --git a/Sources/Runestone/TextView/SearchAndReplace/Untitled.swift b/Sources/Runestone/TextView/SearchAndReplace/Untitled.swift deleted file mode 100644 index e69de29bb..000000000 From c9c97ffba0d46d9af39cbb1ceacbca5f37f3375f Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sat, 22 Nov 2025 16:43:11 +0200 Subject: [PATCH 226/232] Fix finding closest index for CGPoint on iOS and Mac. --- .../Runestone/TextView/Core/LayoutManager.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index db36a1f31..d9cbcdece 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -117,6 +117,18 @@ final class LayoutManager { private let lineNumbersContainerView = FlippedView() // MARK: - Sizing + private var leadingLineSpacing: CGFloat { + #if os(iOS) + if showLineNumbers { + return gutterWidthService.gutterWidth + textContainerInset.left + } else { + return textContainerInset.left + } + #endif + #if os(macOS) + return 0 + #endif + } private var insetViewport: CGRect { let x = viewport.minX - textContainerInset.left let y = viewport.minY - textContainerInset.top @@ -247,7 +259,7 @@ extension LayoutManager { } func closestIndex(to point: CGPoint) -> Int { - let adjustedXPosition = point.x + 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 = lineControllerStorage[line.id] { From c67f1a3e058a6d37d35189ca471fd12cad9999a6 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Sun, 23 Nov 2025 20:34:35 +0200 Subject: [PATCH 227/232] Refactor highlighted ranges to support other categories in addition to just search highlights. Add text color to highlighted ranges and use it when rendering text. Use corner radius for search highlights. --- .../TextView/Appearance/DefaultTheme.swift | 8 ++-- .../TextView/Core/Mac/TextView_Mac+Find.swift | 2 +- .../TextView/Core/Mac/TextView_Mac.swift | 21 ++++++++++ .../TextViewController.swift | 21 ++++++++++ .../TextView/Core/iOS/TextView_iOS.swift | 21 ++++++++++ .../TextView/Highlight/HighlightService.swift | 40 +++++++++++++++--- .../TextView/Highlight/HighlightedRange.swift | 20 ++++++++- .../Highlight/HighlightedRangeFragment.swift | 5 ++- .../LineController/LineFragment.swift | 7 ++++ .../LineController/LineFragmentRenderer.swift | 41 ++++++++++++++++++- .../LineController/LineTypesetter.swift | 8 ++++ .../SearchAndReplace/Mac/FindController.swift | 17 ++++---- .../iOS/UITextSearchingHelper.swift | 11 +++-- 13 files changed, 196 insertions(+), 26 deletions(-) diff --git a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift index 8f0560d76..359f888ed 100644 --- a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift +++ b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift @@ -78,10 +78,10 @@ public final class DefaultTheme: Runestone.Theme { switch style { case .found: let color = MultiPlatformColor(themeColorNamed: "search_match_found") - return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 2) + return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 3) case .highlighted: let color = MultiPlatformColor(themeColorNamed: "search_match_highlighted") - return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 2) + return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 3) case .normal: return nil @unknown default: @@ -92,10 +92,10 @@ public final class DefaultTheme: Runestone.Theme { public func highlightedRange(forFoundTextRange foundTextRange: NSRange, isSelected: Bool) -> HighlightedRange? { if isSelected { let color = MultiPlatformColor(themeColorNamed: "search_match_highlighted") - return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 2) + return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 3) } else { let color = MultiPlatformColor(themeColorNamed: "search_match_found") - return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 2) + return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 3) } } #endif diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift index ad8a1f398..aa51556ef 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift @@ -75,7 +75,7 @@ extension TextView { findController.refreshSearch() } else { // This text view is not being searched - just clear any old highlights - highlightedRanges.removeAll() + removeHighlights(forCategory: .search) } } diff --git a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift index 32c86c057..327730ccb 100644 --- a/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -661,6 +661,27 @@ open class TextView: NSView, NSMenuItemValidation { textViewController.highlightNavigationController.selectRange(at: index) } + /// Sets highlighted ranges for a specific category. + /// - Parameters: + /// - ranges: The highlighted ranges to set. + /// - category: The category to set ranges for. + public func setHighlightedRanges(_ ranges: [HighlightedRange], forCategory category: HighlightCategory) { + textViewController.setHighlightedRanges(ranges, forCategory: category) + } + + /// Returns highlighted ranges for a specific category. + /// - Parameter category: The category to get ranges for. + /// - Returns: Array of highlighted ranges in the specified category. + public func highlightedRanges(forCategory category: HighlightCategory) -> [HighlightedRange] { + textViewController.highlightedRanges(forCategory: category) + } + + /// Removes all highlighted ranges in a specific category. + /// - Parameter category: The category to remove ranges from. + public func removeHighlights(forCategory category: HighlightCategory) { + textViewController.removeHighlights(forCategory: category) + } + /// Scrolls the text view to reveal the text in the specified range. /// /// The function will scroll the text view as little as possible while revealing as much as possible of the specified range. It is not guaranteed that the entire range is visible after performing the scroll. diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index ba6e7eaa8..d83d3eaea 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -522,6 +522,26 @@ final class TextViewController { } } } + + func setHighlightedRanges(_ ranges: [HighlightedRange], forCategory category: HighlightCategory) { + highlightService.setHighlightedRanges(ranges, forCategory: category) + let allRanges = highlightService.highlightedRanges + layoutManager.setNeedsLayout() + layoutManager.layoutIfNeeded() + highlightNavigationController.highlightedRanges = allRanges + } + + func highlightedRanges(forCategory category: HighlightCategory) -> [HighlightedRange] { + highlightService.highlightedRanges(forCategory: category) + } + + func removeHighlights(forCategory category: HighlightCategory) { + highlightService.removeHighlights(forCategory: category) + let allRanges = highlightService.highlightedRanges + layoutManager.setNeedsLayout() + layoutManager.layoutIfNeeded() + highlightNavigationController.highlightedRanges = allRanges + } var highlightedRangeLoopingMode: HighlightedRangeLoopingMode { get { if highlightNavigationController.loopRanges { @@ -623,6 +643,7 @@ final class TextViewController { layoutManager.lineManager = state.lineManager contentSizeService.invalidateContentSize() gutterWidthService.invalidateLineNumberWidth() + highlightedRanges = [] if addUndoAction { if newText != oldText { let newRange = NSRange(location: 0, length: newText.length) diff --git a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift index f58e59b72..27b298c65 100644 --- a/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -925,6 +925,27 @@ open class TextView: UIScrollView { inputDelegate?.selectionDidChange(self) } + /// Sets highlighted ranges for a specific category. + /// - Parameters: + /// - ranges: The highlighted ranges to set. + /// - category: The category to set ranges for. + public func setHighlightedRanges(_ ranges: [HighlightedRange], forCategory category: HighlightCategory) { + textViewController.setHighlightedRanges(ranges, forCategory: category) + } + + /// Returns highlighted ranges for a specific category. + /// - Parameter category: The category to get ranges for. + /// - Returns: Array of highlighted ranges in the specified category. + public func highlightedRanges(forCategory category: HighlightCategory) -> [HighlightedRange] { + textViewController.highlightedRanges(forCategory: category) + } + + /// Removes all highlighted ranges in a specific category. + /// - Parameter category: The category to remove ranges from. + public func removeHighlights(forCategory category: HighlightCategory) { + textViewController.removeHighlights(forCategory: category) + } + /// Synchronously displays the visible lines. This can be used to immediately update the visible lines after setting the theme. Use with caution as redisplaying the visible lines can be a costly operation. public func redisplayVisibleLines() { textViewController.layoutManager.redisplayVisibleLines() diff --git a/Sources/Runestone/TextView/Highlight/HighlightService.swift b/Sources/Runestone/TextView/Highlight/HighlightService.swift index e9885a080..cefffaf25 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightService.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightService.swift @@ -8,14 +8,23 @@ final class HighlightService { } } } - var highlightedRanges: [HighlightedRange] = [] { - didSet { - if highlightedRanges != oldValue { - invalidateHighlightedRangeFragments() - } + var highlightedRanges: [HighlightedRange] { + get { + mergedHighlightedRanges + } + set { + // For backward compatibility: setting highlightedRanges directly puts everything in .custom("") category + highlightedRangesByCategory = [.custom(""): newValue] + updateMergedRanges() } } + private var highlightedRangesByCategory: [HighlightCategory: [HighlightedRange]] = [:] { + didSet { + updateMergedRanges() + } + } + private var mergedHighlightedRanges: [HighlightedRange] = [] private var highlightedRangeFragmentsPerLine: [DocumentLineNodeID: [HighlightedRangeFragment]] = [:] private var highlightedRangeFragmentsPerLineFragment: [LineFragmentID: [HighlightedRangeFragment]] = [:] @@ -23,6 +32,18 @@ final class HighlightService { self.lineManager = lineManager } + func setHighlightedRanges(_ ranges: [HighlightedRange], forCategory category: HighlightCategory) { + highlightedRangesByCategory[category] = ranges + } + + func highlightedRanges(forCategory category: HighlightCategory) -> [HighlightedRange] { + highlightedRangesByCategory[category] ?? [] + } + + func removeHighlights(forCategory category: HighlightCategory) { + highlightedRangesByCategory.removeValue(forKey: category) + } + func highlightedRangeFragments(for lineFragment: LineFragment, inLineWithID lineID: DocumentLineNodeID) -> [HighlightedRangeFragment] { if let lineFragmentHighlightRangeFragments = highlightedRangeFragmentsPerLineFragment[lineFragment.id] { return lineFragmentHighlightRangeFragments @@ -35,6 +56,13 @@ final class HighlightService { } private extension HighlightService { + private func updateMergedRanges() { + // Merge all categories and sort by priority (lower priority first, so higher priority draws on top) + let allRanges = highlightedRangesByCategory.values.flatMap { $0 } + mergedHighlightedRanges = allRanges.sorted { $0.priority < $1.priority } + invalidateHighlightedRangeFragments() + } + private func invalidateHighlightedRangeFragments() { highlightedRangeFragmentsPerLine.removeAll() highlightedRangeFragmentsPerLineFragment.removeAll() @@ -58,6 +86,7 @@ private extension HighlightService { containsStart: containsStart, containsEnd: containsEnd, color: highlightedRange.color, + textColor: highlightedRange.textColor, cornerRadius: highlightedRange.cornerRadius) if let existingHighlightedRangeFragments = result[line.id] { result[line.id] = existingHighlightedRangeFragments + [highlightedRangeFragment] @@ -85,6 +114,7 @@ private extension HighlightService { containsStart: containsStart, containsEnd: containsEnd, color: lineHighlightedRangeFragment.color, + textColor: lineHighlightedRangeFragment.textColor, cornerRadius: lineHighlightedRangeFragment.cornerRadius) } } diff --git a/Sources/Runestone/TextView/Highlight/HighlightedRange.swift b/Sources/Runestone/TextView/Highlight/HighlightedRange.swift index 49ceddebf..40d237c19 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightedRange.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightedRange.swift @@ -1,5 +1,13 @@ import Foundation +/// Category of a highlighted range. +public enum HighlightCategory: Hashable, Sendable { + /// Highlights created by the search/find functionality. + case search + /// Custom application-defined highlight category. + case custom(String) +} + /// Range of text to highlight. public final class HighlightedRange { /// Unique identifier of the highlighted range. @@ -8,26 +16,34 @@ public final class HighlightedRange { public let range: NSRange /// Color to highlight the text with. public let color: MultiPlatformColor + /// Optional text color to use for the highlighted text. If nil, the default text color is used. + public let textColor: MultiPlatformColor? /// Corner radius of the highlight. public let cornerRadius: CGFloat + /// Priority for rendering order. Higher priority highlights are drawn on top of lower priority ones when overlapping. + public let priority: Int /// Create a new highlighted range. /// - Parameters: /// - id: ID of the range. Defaults to a UUID. /// - range: Range in the text to highlight. /// - color: Color to highlight the text with. + /// - textColor: Optional text color for the highlighted text. Defaults to nil (uses default text color). /// - cornerRadius: Corner radius of the highlight. A value of zero or less means no corner radius. Defaults to 0. - public init(id: String = UUID().uuidString, range: NSRange, color: MultiPlatformColor, cornerRadius: CGFloat = 0) { + /// - priority: Priority for rendering order. Higher values are drawn on top. Defaults to 0. + public init(id: String = UUID().uuidString, range: NSRange, color: MultiPlatformColor, textColor: MultiPlatformColor? = nil, cornerRadius: CGFloat = 0, priority: Int = 0) { self.id = id self.range = range self.color = color + self.textColor = textColor self.cornerRadius = cornerRadius + self.priority = priority } } extension HighlightedRange: Equatable { public static func == (lhs: HighlightedRange, rhs: HighlightedRange) -> Bool { - lhs.id == rhs.id && lhs.range == rhs.range && lhs.color == rhs.color + lhs.id == rhs.id && lhs.range == rhs.range && lhs.color == rhs.color && lhs.textColor == rhs.textColor && lhs.priority == rhs.priority } } diff --git a/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift b/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift index 166dbee10..9611bb727 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift @@ -5,13 +5,15 @@ final class HighlightedRangeFragment: Equatable { let containsStart: Bool let containsEnd: Bool let color: MultiPlatformColor + let textColor: MultiPlatformColor? let cornerRadius: CGFloat - init(range: NSRange, containsStart: Bool, containsEnd: Bool, color: MultiPlatformColor, cornerRadius: CGFloat) { + init(range: NSRange, containsStart: Bool, containsEnd: Bool, color: MultiPlatformColor, textColor: MultiPlatformColor? = nil, cornerRadius: CGFloat) { self.range = range self.containsStart = containsStart self.containsEnd = containsEnd self.color = color + self.textColor = textColor self.cornerRadius = cornerRadius } } @@ -22,6 +24,7 @@ extension HighlightedRangeFragment { && lhs.containsStart == rhs.containsStart && lhs.containsEnd == rhs.containsEnd && lhs.color == rhs.color + && lhs.textColor == rhs.textColor && lhs.cornerRadius == rhs.cornerRadius } } diff --git a/Sources/Runestone/TextView/LineController/LineFragment.swift b/Sources/Runestone/TextView/LineController/LineFragment.swift index 96c41efc9..2168e9941 100644 --- a/Sources/Runestone/TextView/LineController/LineFragment.swift +++ b/Sources/Runestone/TextView/LineController/LineFragment.swift @@ -30,6 +30,8 @@ final class LineFragment { let hiddenLength: Int /// The underlying line. let line: CTLine + /// The attributed string for this line fragment. + let attributedString: NSAttributedString /// The lenth of the descent. let descent: CGFloat /// The non-scaled height of the line fragment. @@ -54,6 +56,7 @@ final class LineFragment { index: Int, visibleRange: NSRange, line: CTLine, + attributedString: NSAttributedString, descent: CGFloat, baseSize: CGSize, scaledSize: CGSize, @@ -65,6 +68,7 @@ final class LineFragment { visibleRange: visibleRange, hiddenLength: 0, line: line, + attributedString: attributedString, descent: descent, baseSize: baseSize, scaledSize: scaledSize, @@ -78,6 +82,7 @@ final class LineFragment { visibleRange: NSRange, hiddenLength: Int, line: CTLine, + attributedString: NSAttributedString, descent: CGFloat, baseSize: CGSize, scaledSize: CGSize, @@ -88,6 +93,7 @@ final class LineFragment { self.visibleRange = visibleRange self.hiddenLength = hiddenLength self.line = line + self.attributedString = attributedString self.descent = descent self.baseSize = baseSize self.scaledSize = scaledSize @@ -101,6 +107,7 @@ final class LineFragment { visibleRange: visibleRange, hiddenLength: hiddenLength, line: line, + attributedString: attributedString, descent: descent, baseSize: baseSize, scaledSize: scaledSize, diff --git a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift index 55bcb6ca0..c77dbd8c3 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift @@ -108,10 +108,49 @@ private extension LineFragmentRenderer { context.scaleBy(x: 1, y: -1) let yPosition = lineFragment.descent + (lineFragment.scaledSize.height - lineFragment.baseSize.height) / 2 context.textPosition = CGPoint(x: 0, y: yPosition) - CTLineDraw(lineFragment.line, context) + + // Check if any highlighted ranges have custom text colors + let hasCustomTextColors = highlightedRangeFragments.contains { $0.textColor != nil } + + if hasCustomTextColors { + // Create modified attributed string with text color overrides + let modifiedAttributedString = applyTextColorOverrides(to: lineFragment.attributedString) + // Create new CTLine from modified attributed string + let modifiedLine = CTLineCreateWithAttributedString(modifiedAttributedString) + CTLineDraw(modifiedLine, context) + } else { + // Use existing line (fast path) + CTLineDraw(lineFragment.line, context) + } + context.restoreGState() } + private func applyTextColorOverrides(to attributedString: NSAttributedString) -> NSAttributedString { + let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) + + // Sort fragments by priority (higher priority wins for overlapping ranges) + let sortedFragments = highlightedRangeFragments.sorted { $0.range.location < $1.range.location } + + // Apply text color overrides for each fragment with custom text color + for fragment in sortedFragments { + guard let textColor = fragment.textColor else { continue } + + // Convert from line-local coordinates to attributed-string-local coordinates + // fragment.range is in line-local coords, but attributedString uses visibleRange-local coords + let fragmentLocalRange = fragment.range.local(to: lineFragment.visibleRange) + + // Add foreground color attribute for this range + mutableAttributedString.addAttribute( + .foregroundColor, + value: textColor, + range: fragmentLocalRange + ) + } + + return mutableAttributedString + } + private func drawInvisibleCharacters(in string: String) { var indexInLineFragment = 0 for substring in string { diff --git a/Sources/Runestone/TextView/LineController/LineTypesetter.swift b/Sources/Runestone/TextView/LineController/LineTypesetter.swift index 56a79d7ff..76c9eabf2 100644 --- a/Sources/Runestone/TextView/LineController/LineTypesetter.swift +++ b/Sources/Runestone/TextView/LineController/LineTypesetter.swift @@ -232,11 +232,19 @@ private extension LineTypesetter { let scaledSize = CGSize(width: width, height: height * lineFragmentHeightMultiplier) let id = LineFragmentID(lineId: lineID, lineFragmentIndex: lineFragmentIndex) let visibleRange = NSRange(location: visibleRange.location, length: visibleRange.length) + // Extract attributed string substring for this line fragment + let fragmentAttributedString: NSAttributedString + if let attributedString, visibleRange.location + visibleRange.length <= attributedString.length { + fragmentAttributedString = attributedString.attributedSubstring(from: visibleRange) + } else { + fragmentAttributedString = NSAttributedString() + } return LineFragment( id: id, index: lineFragmentIndex, visibleRange: visibleRange, line: line, + attributedString: fragmentAttributedString, descent: descent, baseSize: baseSize, scaledSize: scaledSize, diff --git a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift index 6ae167f6a..a0fabfd14 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift @@ -10,9 +10,9 @@ final class FindController: NSObject { updateReplaceButtonState() guard textView != oldValue else { return } - - oldValue?.highlightedRanges.removeAll() - + + oldValue?.removeHighlights(forCategory: .search) + // When switching to a different text view, re-run the search if findPanelWindow.isVisible, !searchQuery.isEmpty { performSearch(query: searchQuery, options: searchOptions) @@ -241,7 +241,7 @@ final class FindController: NSObject { guard let textView else { return } guard !searchResults.isEmpty else { - textView.highlightedRanges.removeAll() + textView.removeHighlights(forCategory: .search) return } @@ -259,12 +259,12 @@ final class FindController: NSObject { highlightedRanges.append(highlightedRange) } } - - textView.highlightedRanges = highlightedRanges + + textView.setHighlightedRanges(highlightedRanges, forCategory: .search) } private func clearSearchHighlights() { - textView?.highlightedRanges.removeAll() + textView?.removeHighlights(forCategory: .search) } private func scrollToCurrentMatch() { @@ -298,7 +298,8 @@ extension FindController: FindPanelDelegate { let matchRange = searchResults[searchResultIndex].range // Check if we can replace - if let highlightedRange = textView.highlightedRanges.first(where: { $0.range == matchRange }) { + let searchHighlights = textView.highlightedRanges(forCategory: .search) + if let highlightedRange = searchHighlights.first(where: { $0.range == matchRange }) { if let canReplace = textView.editorDelegate?.textView(textView, canReplaceTextIn: highlightedRange), !canReplace { return } diff --git a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift index b14205fb4..305f6e565 100644 --- a/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift +++ b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift @@ -92,14 +92,16 @@ extension UITextSearchingHelper: UITextSearching { guard let foundTextRange = foundTextRange as? IndexedRange else { return } - _textView.highlightedRanges.removeAll { $0.range == foundTextRange.range } + var searchHighlights = _textView.highlightedRanges(forCategory: .search) + searchHighlights.removeAll { $0.range == foundTextRange.range } if let highlightedRange = _textView.theme.highlightedRange(forFoundTextRange: foundTextRange.range, ofStyle: style) { - _textView.highlightedRanges.append(highlightedRange) + searchHighlights.append(highlightedRange) } + _textView.setHighlightedRanges(searchHighlights, forCategory: .search) } func clearAllDecoratedFoundText() { - _textView.highlightedRanges = [] + _textView.removeHighlights(forCategory: .search) } func replaceAll(queryString: String, options: UITextSearchOptions, withText replacementText: String) { @@ -121,7 +123,8 @@ extension UITextSearchingHelper: UITextSearching { // iOS 16 beta 2 will call this function when presenting the find/replace navigator and pass to foundTextRange. If we return false in this case, the find/replace UI will not be shown, so we need to return true when we can't convert the UITextRange to an IndexedRange. return true } - guard let highlightedRange = _textView.highlightedRanges.first(where: { $0.range == foundTextRange.range }) else { + let searchHighlights = _textView.highlightedRanges(forCategory: .search) + guard let highlightedRange = searchHighlights.first(where: { $0.range == foundTextRange.range }) else { return false } return _textView.editorDelegate?.textView(_textView, canReplaceTextIn: highlightedRange) ?? false From 0eaac749a6005a63c3c4c4569cbf94b9aba6c053 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 24 Nov 2025 00:23:14 +0200 Subject: [PATCH 228/232] Fix replacementText value in textView shouldChangeTextIn delegate callback to correct string. --- .../Core/TextViewController/TextViewController+Editing.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift index 575eedd91..a3b795b61 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift @@ -107,7 +107,7 @@ private extension TextViewController { } private func delegateAllowsChangeText(in range: NSRange, withReplacementText replacementText: String) -> Bool { - textView.editorDelegate?.textView(textView, shouldChangeTextIn: range, replacementText: text) ?? true + textView.editorDelegate?.textView(textView, shouldChangeTextIn: range, replacementText: replacementText) ?? true } private func insertLeadingComponent(of characterPair: CharacterPair, in range: NSRange) -> Bool { From deca6bb79377488b700aae86323e0a3cd5c41b4a Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 24 Nov 2025 00:24:08 +0200 Subject: [PATCH 229/232] Call text view delegate textView shouldChangeTextIn method when replacing text in registered undo actions. --- .../Core/TextViewController/TextViewController+UndoRedo.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift index a6f0c97fa..1841744f9 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift @@ -14,7 +14,9 @@ extension TextViewController { #if os(iOS) textViewController.textView.inputDelegate?.selectionWillChange(textViewController.textView) #endif - textViewController.replaceText(in: range, with: text) + if textViewController.textView.editorDelegate?.textView(textViewController.textView, shouldChangeTextIn: range, replacementText: text) ?? true { + textViewController.replaceText(in: range, with: text) + } textViewController.selectedRange = oldSelectedRange #if os(iOS) textViewController.textView.inputDelegate?.selectionDidChange(textViewController.textView) From a57cde568710f30c307810981374ed3dd6a48eac Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 24 Nov 2025 00:34:42 +0200 Subject: [PATCH 230/232] Fix assigning highlighted ranges without category to HighlightService. --- Sources/Runestone/TextView/Highlight/HighlightService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Runestone/TextView/Highlight/HighlightService.swift b/Sources/Runestone/TextView/Highlight/HighlightService.swift index cefffaf25..5a3b6a200 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightService.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightService.swift @@ -14,7 +14,7 @@ final class HighlightService { } set { // For backward compatibility: setting highlightedRanges directly puts everything in .custom("") category - highlightedRangesByCategory = [.custom(""): newValue] + highlightedRangesByCategory[.custom("")] = newValue updateMergedRanges() } } From bd501b8bad00608c0e815315ade2cb8b357d5b04 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 24 Nov 2025 01:12:23 +0200 Subject: [PATCH 231/232] Fix applying text color overrides. --- .../TextView/LineController/LineFragmentRenderer.swift | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift index c77dbd8c3..3b9380114 100644 --- a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift +++ b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift @@ -137,8 +137,9 @@ private extension LineFragmentRenderer { guard let textColor = fragment.textColor else { continue } // Convert from line-local coordinates to attributed-string-local coordinates - // fragment.range is in line-local coords, but attributedString uses visibleRange-local coords - let fragmentLocalRange = fragment.range.local(to: lineFragment.visibleRange) + let fragmentLocalRange = fragment.range + .capped(to: lineFragment.visibleRange) + .local(to: lineFragment.visibleRange) // Add foreground color attribute for this range mutableAttributedString.addAttribute( From e8e0a90320875486de2acdb9488a95f84089b253 Mon Sep 17 00:00:00 2001 From: Pasi Salenius Date: Mon, 24 Nov 2025 10:35:04 +0200 Subject: [PATCH 232/232] Invalidate highlighted range fragments when size is invalidated so that highlights appear at correct positions. --- .../Core/TextViewController/TextViewController.swift | 1 + .../TextView/Highlight/HighlightService.swift | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift index d83d3eaea..773f37ae3 100644 --- a/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -776,6 +776,7 @@ extension TextViewController: LineControllerDelegate { } func lineControllerDidInvalidateSize(_ lineController: LineController) { + highlightService.invalidateHighlightedRangeFragments() textView.setNeedsLayout() layoutManager.setNeedsLayout() } diff --git a/Sources/Runestone/TextView/Highlight/HighlightService.swift b/Sources/Runestone/TextView/Highlight/HighlightService.swift index 5a3b6a200..8d99ff0e6 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightService.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightService.swift @@ -53,6 +53,12 @@ final class HighlightService { return highlightedLineFragments } } + + func invalidateHighlightedRangeFragments() { + highlightedRangeFragmentsPerLine.removeAll() + highlightedRangeFragmentsPerLineFragment.removeAll() + highlightedRangeFragmentsPerLine = createHighlightedRangeFragmentsPerLine() + } } private extension HighlightService { @@ -63,12 +69,6 @@ private extension HighlightService { invalidateHighlightedRangeFragments() } - private func invalidateHighlightedRangeFragments() { - highlightedRangeFragmentsPerLine.removeAll() - highlightedRangeFragmentsPerLineFragment.removeAll() - highlightedRangeFragmentsPerLine = createHighlightedRangeFragmentsPerLine() - } - private func createHighlightedRangeFragmentsPerLine() -> [DocumentLineNodeID: [HighlightedRangeFragment]] { var result: [DocumentLineNodeID: [HighlightedRangeFragment]] = [:] for highlightedRange in highlightedRanges where highlightedRange.range.length > 0 {