diff --git a/Example/Example.xcodeproj/project.pbxproj b/Example/Example.xcodeproj/project.pbxproj index f7fe981b5..a4abc3a6a 100644 --- a/Example/Example.xcodeproj/project.pbxproj +++ b/Example/Example.xcodeproj/project.pbxproj @@ -7,6 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + 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 */; }; + 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 */; }; @@ -34,6 +40,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 720F5F74298D1B8F00C64EC3 /* BasicCharacterPair.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasicCharacterPair.swift; sourceTree = ""; }; 7216EAC62829A16C001B6D39 /* Themes */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Themes; sourceTree = ""; }; 72417DC92B4E7315009EB32B /* SwiftUIMenuButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIMenuButton.swift; sourceTree = ""; }; 72417DCF2B4E7492009EB32B /* MenuSelectionHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuSelectionHandler.swift; sourceTree = ""; }; @@ -51,7 +58,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 = ""; }; @@ -61,6 +68,20 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 729ECE492983F5B60049AFF5 /* Frameworks */ = { + 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; + }; ACFDF4AA27983BAA00059A1B /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -144,7 +165,8 @@ isa = PBXGroup; children = ( 72AC54722826B0E40037ED21 /* Packages */, - ACFDF4AF27983BAA00059A1B /* Example */, + ACFDF4AF27983BAA00059A1B /* iOSExample */, + 729ECE4D2983F5B60049AFF5 /* MacExample */, ACFDF4AE27983BAA00059A1B /* Products */, ACFDF4C827983DA900059A1B /* Frameworks */, ); @@ -153,15 +175,16 @@ ACFDF4AE27983BAA00059A1B /* Products */ = { isa = PBXGroup; children = ( - ACFDF4AD27983BAA00059A1B /* Example.app */, + ACFDF4AD27983BAA00059A1B /* iOSExample.app */, + 729ECE4C2983F5B60049AFF5 /* MacExample.app */, ); name = Products; sourceTree = ""; }; - ACFDF4AF27983BAA00059A1B /* Example */ = { + ACFDF4AF27983BAA00059A1B /* iOSExample */ = { isa = PBXGroup; children = ( - 7243F9BA282D73E9005AAABF /* Example.entitlements */, + 7243F9BA282D73E9005AAABF /* iOSExample.entitlements */, ACFDF4BE27983BAB00059A1B /* Info.plist */, ACFDF4B927983BAB00059A1B /* Assets.xcassets */, AC832D582798C72A00EC6832 /* Application */, @@ -169,7 +192,7 @@ AC832D592798C73300EC6832 /* Main */, ACB08ACF27A8112600EB6819 /* ThemePicker */, ); - path = Example; + path = iOSExample; sourceTree = ""; }; ACFDF4C827983DA900059A1B /* Frameworks */ = { @@ -182,9 +205,36 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - ACFDF4AC27983BAA00059A1B /* Example */ = { + 729ECE4B2983F5B60049AFF5 /* MacExample */ = { isa = PBXNativeTarget; - buildConfigurationList = ACFDF4C127983BAB00059A1B /* Build configuration list for PBXNativeTarget "Example" */; + 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 */, + 721103582989ACDD00DDFE48 /* RunestoneOneDarkTheme */, + 7211035A2989ACDD00DDFE48 /* RunestonePlainTextTheme */, + 7211035C2989ACDD00DDFE48 /* RunestoneThemeCommon */, + 7211035E2989ACDD00DDFE48 /* RunestoneTomorrowNightTheme */, + 721103602989ACDD00DDFE48 /* RunestoneTomorrowTheme */, + ); + productName = MacExample; + productReference = 729ECE4C2983F5B60049AFF5 /* MacExample.app */; + productType = "com.apple.product-type.application"; + }; + ACFDF4AC27983BAA00059A1B /* iOSExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = ACFDF4C127983BAB00059A1B /* Build configuration list for PBXNativeTarget "iOSExample" */; buildPhases = ( ACFDF4A927983BAA00059A1B /* Sources */, ACFDF4AA27983BAA00059A1B /* Frameworks */, @@ -195,7 +245,7 @@ ); dependencies = ( ); - name = Example; + name = iOSExample; packageProductDependencies = ( 72AC54802826B2A90037ED21 /* Runestone */, 7216EAC72829A3C6001B6D39 /* RunestoneJavaScriptLanguage */, @@ -205,7 +255,7 @@ 7216EACF2829A3C6001B6D39 /* RunestoneTomorrowTheme */, ); productName = Example; - productReference = ACFDF4AD27983BAA00059A1B /* Example.app */; + productReference = ACFDF4AD27983BAA00059A1B /* iOSExample.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -215,9 +265,12 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 1410; + LastSwiftUpdateCheck = 1420; LastUpgradeCheck = 1320; TargetAttributes = { + 729ECE4B2983F5B60049AFF5 = { + CreatedOnToolsVersion = 14.2; + }; ACFDF4AC27983BAA00059A1B = { CreatedOnToolsVersion = 13.2; }; @@ -238,12 +291,22 @@ projectDirPath = ""; projectRoot = ""; targets = ( - ACFDF4AC27983BAA00059A1B /* Example */, + 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; @@ -255,6 +318,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; @@ -277,6 +359,16 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 729ECE482983F5B60049AFF5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 720F5F75298D1B8F00C64EC3 /* BasicCharacterPair.swift in Sources */, + 729ECE512983F5B60049AFF5 /* MainViewController.swift in Sources */, + 729ECE4F2983F5B60049AFF5 /* AppDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; ACFDF4A927983BAA00059A1B /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -304,6 +396,66 @@ /* End PBXSourcesBuildPhase 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 = 6ZSJ45TPYC; + 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 = 6ZSJ45TPYC; + 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 = { @@ -425,12 +577,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; + DEVELOPMENT_TEAM = 6ZSJ45TPYC; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Example/Info.plist; + INFOPLIST_FILE = iOSExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -457,12 +609,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; + DEVELOPMENT_TEAM = 6ZSJ45TPYC; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Example/Info.plist; + INFOPLIST_FILE = iOSExample/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -487,6 +639,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 = ( @@ -496,7 +657,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - ACFDF4C127983BAB00059A1B /* Build configuration list for PBXNativeTarget "Example" */ = { + ACFDF4C127983BAB00059A1B /* Build configuration list for PBXNativeTarget "iOSExample" */ = { isa = XCConfigurationList; buildConfigurations = ( ACFDF4C227983BAB00059A1B /* Debug */, @@ -508,6 +669,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; @@ -528,6 +709,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/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/Languages/Package.swift b/Example/Languages/Package.swift index 396ae8dd7..d1f4c92fb 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..ba8866110 --- /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 { + true + } +} diff --git a/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/MacExample/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json rename to Example/MacExample/Assets.xcassets/AccentColor.colorset/Contents.json 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..432c402c6 --- /dev/null +++ b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "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" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 000000000..f95b52f8d Binary files /dev/null and b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_128x128.png differ 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 000000000..fabfa6a5f Binary files /dev/null and b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_128x128@2x.png differ 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 000000000..67819353a Binary files /dev/null and b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_16x16.png differ 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 000000000..ec8a213b9 Binary files /dev/null and b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_16x16@2x.png differ 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 000000000..fabfa6a5f Binary files /dev/null and b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_256x256.png differ 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 000000000..6c3e5d7fd Binary files /dev/null and b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_256x256@2x.png differ 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 000000000..ec8a213b9 Binary files /dev/null and b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_32x32.png differ 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 000000000..eba2103f0 Binary files /dev/null and b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_32x32@2x.png differ 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 000000000..6c3e5d7fd Binary files /dev/null and b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_512x512.png differ 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 000000000..52e7f4c27 Binary files /dev/null and b/Example/MacExample/Assets.xcassets/AppIcon.appiconset/ExampleProjectMac_512x512@2x.png differ diff --git a/Example/Example/Assets.xcassets/Contents.json b/Example/MacExample/Assets.xcassets/Contents.json similarity index 100% rename from Example/Example/Assets.xcassets/Contents.json rename to Example/MacExample/Assets.xcassets/Contents.json diff --git a/Example/MacExample/Base.lproj/Main.storyboard b/Example/MacExample/Base.lproj/Main.storyboard new file mode 100644 index 000000000..6feb6c400 --- /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/Example/Library/BasicCharacterPair.swift b/Example/MacExample/BasicCharacterPair.swift similarity index 100% rename from Example/Example/Library/BasicCharacterPair.swift rename to Example/MacExample/BasicCharacterPair.swift 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..7a65e53ac --- /dev/null +++ b/Example/MacExample/MainViewController.swift @@ -0,0 +1,87 @@ +import Cocoa +import Runestone +import RunestoneJavaScriptLanguage +import RunestoneOneDarkTheme +import RunestoneThemeCommon +import RunestoneTomorrowNightTheme +import RunestoneTomorrowTheme + +final class MainViewController: NSViewController { + private let theme: EditorTheme = OneDarkTheme() + private let textView: TextView = { + let this = TextView() + this.translatesAutoresizingMaskIntoConstraints = false + 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.3 + this.kern = 0.3 + this.lineSelectionDisplayType = .line + this.gutterLeadingPadding = 4 + this.gutterTrailingPadding = 4 + this.isLineWrappingEnabled = true + this.indentStrategy = .space(length: 2) + this.characterPairs = [ + BasicCharacterPair(leading: "(", trailing: ")"), + BasicCharacterPair(leading: "{", trailing: "}"), + BasicCharacterPair(leading: "[", trailing: "]"), + BasicCharacterPair(leading: "\"", trailing: "\""), + BasicCharacterPair(leading: "'", trailing: "'") + ] + return this + }() + + override var acceptsFirstResponder: Bool { + true + } + + override func viewDidLoad() { + super.viewDidLoad() + view.appearance = NSAppearance(named: .vibrantDark) + setupTextView() + applyTheme(theme) + // 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) + } + + override func viewDidAppear() { + super.viewDidAppear() + view.window?.backgroundColor = theme.backgroundColor + } +} + +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) + ]) + } + + private func applyTheme(_ theme: EditorTheme) { + textView.theme = theme + textView.wantsLayer = true + textView.layer?.backgroundColor = theme.backgroundColor.cgColor + textView.insertionPointColor = theme.textColor + textView.selectionHighlightColor = theme.textColor.withAlphaComponent(0.2) + } +} 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"]), diff --git a/Example/Themes/Sources/RunestoneOneDarkTheme/OneDarkTheme.swift b/Example/Themes/Sources/RunestoneOneDarkTheme/OneDarkTheme.swift index 2b5cc0201..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") - case .variableBuiltin: - return UIColor(namedInModule: "OneDarkRed") + return MultiPlatformColor(namedInModule: "OneDarkPurple") + case .variableBuiltin, .constantBuiltin: + 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 564a805fa..4676f48e9 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? { 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/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/RunestoneTomorrowNightTheme/TomorrowNightTheme.swift b/Example/Themes/Sources/RunestoneTomorrowNightTheme/TomorrowNightTheme.swift index d2b959517..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") - case .variableBuiltin: - return UIColor(namedInModule: "TomorrowNightRed") + return MultiPlatformColor(namedInModule: "TomorrowNightPurple") + case .variableBuiltin, .constantBuiltin: + 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 b410d6e19..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") - case .variableBuiltin: - return UIColor(namedInModule: "TomorrowRed") + return MultiPlatformColor(namedInModule: "TomorrowPurple") + case .variableBuiltin, .constantBuiltin: + 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 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/iOSExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/iOSExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Example/iOSExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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/iOSExample/Assets.xcassets/Contents.json b/Example/iOSExample/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Example/iOSExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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/iOSExample/Library/BasicCharacterPair.swift b/Example/iOSExample/Library/BasicCharacterPair.swift new file mode 100644 index 000000000..a366cfa95 --- /dev/null +++ b/Example/iOSExample/Library/BasicCharacterPair.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/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 diff --git a/Package.swift b/Package.swift index 9359a393b..f9c367838 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,7 @@ let package = Package( name: "Runestone", defaultLocalization: "en", platforms: [ - .iOS(.v14) + .iOS(.v14), .macOS(.v11) ], products: [ .library(name: "Runestone", targets: ["Runestone"]) diff --git a/Sources/Runestone/Documentation.docc/Extensions/TextView.md b/Sources/Runestone/Documentation.docc/Extensions/TextView.md index ffc35b66e..36ae2f4a8 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()`` +- ``layout()`` +- ``layoutSubviews()`` +- ``safeAreaInsetsDidChange()`` +- ``traitCollectionDidChange(_:)`` +- ``viewDidMoveToWindow()`` +- ``resizeSubviews(withOldSize:)`` +- ``resetCursorRects()`` + ### Responding to Text View Changes - ``editorDelegate`` @@ -15,7 +28,6 @@ ### Configuring the Appearance - ``theme`` -- ``backgroundColor`` - ``kern`` - ``lineHeightMultiplier`` - ``insertionPointColor`` @@ -63,6 +75,7 @@ - ``gutterLeadingPadding`` - ``gutterTrailingPadding`` - ``gutterWidth`` +- ``gutterMinimumCharacterCount`` ### Character Pairs @@ -110,6 +123,7 @@ - ``selectNextHighlightedRange()`` - ``selectPreviousHighlightedRange()`` - ``selectHighlightedRange(at:)`` +- ``showMenuAfterNavigatingToHighlightedRange`` ### Supporting Find and Replace @@ -131,14 +145,19 @@ - ``smartDashesType`` - ``smartInsertDeleteType`` - ``smartQuotesType`` -- ``text(in:)-3lp4v`` -- ``text(in:)-3wzco`` +- ``text(in:)`` - ``insertText(_:)`` +- ``insertText(_:replacementRange:)`` +- ``insertNewline(_:)`` +- ``insertTab(_:)`` - ``replaceText(in:)`` - ``replace(_:withText:)-7gret`` -- ``replace(_:withText:)-7ugo8`` +- ``deleteForward(_:)`` - ``deleteBackward()`` +- ``deleteBackward(_:)`` - ``undoManager`` +- ``undo(_:)`` +- ``redo(_:)`` ### Managing the Keyboard @@ -146,12 +165,11 @@ - ``keyboardType`` - ``returnKeyType`` - ``inputAccessoryView`` -- ``inputAssistantItem`` -- ``reloadInputViews()`` ### Selecting Text - ``selectedRange`` +- ``selectedRange()`` - ``selectedTextRange`` - ``selectionBarColor`` - ``selectionHighlightColor`` @@ -161,34 +179,77 @@ - ``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:)`` +- ``deleteWordForward(_:)`` +- ``deleteWordBackward(_:)`` ### 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/Library/Caret.swift b/Sources/Runestone/Library/Caret.swift index 24677c474..75823f1e4 100644 --- a/Sources/Runestone/Library/Caret.swift +++ b/Sources/Runestone/Library/Caret.swift @@ -1,9 +1,13 @@ -import UIKit +import CoreGraphics enum Caret { + #if os(iOS) static let width: CGFloat = 2 + #else + static let width: CGFloat = 2 + #endif - static func defaultHeight(for font: UIFont?) -> CGFloat { + static func defaultHeight(for font: MultiPlatformFont?) -> CGFloat { font?.lineHeight ?? 15 } } 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 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 diff --git a/Sources/Runestone/Library/L10n.swift b/Sources/Runestone/Library/L10n.swift index 61ab3ff62..18f583337 100644 --- a/Sources/Runestone/Library/L10n.swift +++ b/Sources/Runestone/Library/L10n.swift @@ -12,8 +12,16 @@ import Foundation 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", 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/Library/Mac/NSScrollView+Helpers.swift b/Sources/Runestone/Library/Mac/NSScrollView+Helpers.swift new file mode 100644 index 000000000..ca0be63c4 --- /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 { + 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/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/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) diff --git a/Sources/Runestone/Library/UIFont+Helpers.swift b/Sources/Runestone/Library/UIFont+Helpers.swift deleted file mode 100644 index 7428addac..000000000 --- a/Sources/Runestone/Library/UIFont+Helpers.swift +++ /dev/null @@ -1,7 +0,0 @@ -import UIKit - -extension UIFont { - var totalLineHeight: CGFloat { - ascender + abs(descender) + leading - } -} 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/ViewReuseQueue.swift b/Sources/Runestone/Library/ViewReuseQueue.swift index 53ce6907c..31876b9e4 100644 --- a/Sources/Runestone/Library/ViewReuseQueue.swift +++ b/Sources/Runestone/Library/ViewReuseQueue.swift @@ -1,4 +1,6 @@ +#if os(iOS) import UIKit +#endif protocol ReusableView { func prepareForReuse() @@ -8,21 +10,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) + object: nil + ) + #endif } deinit { + #if os(iOS) NotificationCenter.default.removeObserver(self) + #endif } func enqueueViews(withKeys keys: Set) { @@ -58,7 +65,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 055600d56..bab3aaaa2 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/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/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/MultiPlatform/MultiPlatformColor.swift b/Sources/Runestone/MultiPlatform/MultiPlatformColor.swift new file mode 100644 index 000000000..b1316e3d3 --- /dev/null +++ b/Sources/Runestone/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/MultiPlatform/MultiPlatformEdgeInsets.swift b/Sources/Runestone/MultiPlatform/MultiPlatformEdgeInsets.swift new file mode 100644 index 000000000..b2010eadf --- /dev/null +++ b/Sources/Runestone/MultiPlatform/MultiPlatformEdgeInsets.swift @@ -0,0 +1,21 @@ +#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) + } +} + +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 diff --git a/Sources/Runestone/MultiPlatform/MultiPlatformFont.swift b/Sources/Runestone/MultiPlatform/MultiPlatformFont.swift new file mode 100644 index 000000000..d68bc64e5 --- /dev/null +++ b/Sources/Runestone/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 { + ascender + abs(descender) + leading + } +} + +#if os(macOS) +extension NSFont { + var lineHeight: CGFloat { + ceil(ascender + abs(descender) + leading) + } +} +#endif diff --git a/Sources/Runestone/MultiPlatform/MultiPlatformView.swift b/Sources/Runestone/MultiPlatform/MultiPlatformView.swift new file mode 100644 index 000000000..7e7899394 --- /dev/null +++ b/Sources/Runestone/MultiPlatform/MultiPlatformView.swift @@ -0,0 +1,45 @@ +#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 layoutIfNeeded() {} + + var isFirstResponder: Bool { + window?.firstResponder == self + } +} + +func UIGraphicsGetCurrentContext() -> CGContext? { + NSGraphicsContext.current?.cgContext +} +#endif 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/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/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 diff --git a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift index b392ec33f..359f888ed 100644 --- a/Sources/Runestone/TextView/Appearance/DefaultTheme.swift +++ b/Sources/Runestone/TextView/Appearance/DefaultTheme.swift @@ -1,58 +1,63 @@ +#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 { - 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,25 +72,31 @@ public final class DefaultTheme: Runestone.Theme { } } +#if 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") - return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 2) + let color = MultiPlatformColor(themeColorNamed: "search_match_found") + return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 3) case .highlighted: - let color = UIColor(themeColorNamed: "search_match_highlighted") - return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 2) + let color = MultiPlatformColor(themeColorNamed: "search_match_highlighted") + return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 3) case .normal: return nil @unknown default: return nil } } -} - -private extension UIColor { - convenience init(themeColorNamed name: String) { - self.init(named: "theme_" + name, in: .module, compatibleWith: nil)! +#elseif os(macOS) + 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: 3) + } else { + let color = MultiPlatformColor(themeColorNamed: "search_match_found") + return HighlightedRange(range: foundTextRange, color: color, cornerRadius: 3) + } } +#endif } diff --git a/Sources/Runestone/TextView/Appearance/Theme.swift b/Sources/Runestone/TextView/Appearance/Theme.swift index e81301fa5..14ebfe1c4 100644 --- a/Sources/Runestone/TextView/Appearance/Theme.swift +++ b/Sources/Runestone/TextView/Appearance/Theme.swift @@ -1,48 +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 } - /// Width of the hairline next to the page guide. - var pageGuideHairlineWidth: CGFloat { 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. @@ -51,6 +55,7 @@ public protocol Theme: AnyObject { /// /// See for more information on higlight names. func shadow(for highlightName: String) -> NSShadow? +#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 . @@ -62,22 +67,42 @@ 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. + func highlightedRange(forFoundTextRange foundTextRange: NSRange, isSelected: Bool) -> HighlightedRange? +#endif } public extension Theme { var gutterHairlineWidth: CGFloat { - hairlineLength + #if os(iOS) + return 1 / UIScreen.main.scale + #else + return 1 / NSScreen.main!.backingScaleFactor + #endif } var pageGuideHairlineWidth: CGFloat { - hairlineLength + #if os(iOS) + return 1 / UIScreen.main.scale + #else + return 1 / NSScreen.main!.backingScaleFactor + #endif } var markedTextBackgroundCornerRadius: CGFloat { 0 } - func font(for highlightName: String) -> UIFont? { + func font(for highlightName: String) -> MultiPlatformFont? { nil } @@ -89,6 +114,7 @@ public extension Theme { nil } +#if os(iOS) @available(iOS 16, *) func highlightedRange(forFoundTextRange foundTextRange: NSRange, ofStyle style: UITextSearchFoundTextStyle) -> HighlightedRange? { switch style { @@ -102,4 +128,13 @@ public extension Theme { return nil } } +#elseif os(macOS) + func highlightedRange(forFoundTextRange foundTextRange: NSRange, isSelected: Bool) -> HighlightedRange? { + if isSelected { + return HighlightedRange(range: foundTextRange, color: .systemYellow, cornerRadius: 2) + } else { + return HighlightedRange(range: foundTextRange, color: .systemYellow.withAlphaComponent(0.2), cornerRadius: 2) + } + } +#endif } diff --git a/Sources/Runestone/TextView/Appearance/Theme.xcassets/theme_gutter_background.colorset/Contents.json b/Sources/Runestone/TextView/Appearance/Theme.xcassets/theme_gutter_background.colorset/Contents.json index 98ef9c75a..e25f3ef2f 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 @@ -29,6 +29,32 @@ } }, "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 98ef9c75a..e25f3ef2f 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 @@ -29,6 +29,32 @@ } }, "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 7efa25cd3..245d8defa 100644 --- a/Sources/Runestone/TextView/Core/ContentSizeService.swift +++ b/Sources/Runestone/TextView/Core/ContentSizeService.swift @@ -1,16 +1,23 @@ import Combine -import UIKit +import Foundation final class ContentSizeService { - var safeAreaInset: UIEdgeInsets = .zero - var textContainerInset: UIEdgeInsets = .zero - var scrollViewWidth: CGFloat = 0 { + var safeAreaInset: MultiPlatformEdgeInsets = .zero + var scrollViewSize: CGSize = .zero { didSet { - if scrollViewWidth != oldValue && isLineWrappingEnabled { + if scrollViewSize != oldValue && isLineWrappingEnabled { invalidateContentSize() } } } + var verticalScrollerWidth: CGFloat = 0 { + didSet { + if verticalScrollerWidth != oldValue { + invalidateContentSize() + } + } + } + var textContainerInset: MultiPlatformEdgeInsets = .zero var isLineWrappingEnabled = true { didSet { if isLineWrappingEnabled != oldValue { @@ -18,6 +25,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 +50,11 @@ final class ContentSizeService { } } var contentWidth: CGFloat { - let minimumWidth = scrollViewWidth - safeAreaInset.left - safeAreaInset.right + let minimumWidth = scrollViewSize.width - safeAreaInset.left - safeAreaInset.right - verticalScrollerWidth if isLineWrappingEnabled { return minimumWidth } else { - let textContentWidth = longestLineWidth ?? scrollViewWidth + let textContentWidth = longestLineWidth ?? scrollViewSize.width let preferredWidth = ceil( textContentWidth + gutterWidthService.gutterWidth @@ -48,7 +69,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 @@ -114,10 +139,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 @@ -146,28 +173,35 @@ 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 } } } 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 - } + guard let longestLine = lineManager.initialLongestLine else { + return + } + lineIDTrackingWidth = longestLine.id + let lineController = lineControllerStorage.getOrCreateLineController(for: longestLine) + lineController.invalidateString() + lineController.invalidateTypesetting() + lineController.invalidateSyntaxHighlighting() + lineWidths[longestLine.id] = lineController.lineWidth + if !isLineWrappingEnabled { + _longestLineWidth = nil + isContentSizeInvalid = true } } } 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/LayoutManager.swift b/Sources/Runestone/TextView/Core/LayoutManager.swift index 6ee8f1f8f..d9cbcdece 100644 --- a/Sources/Runestone/TextView/Core/LayoutManager.swift +++ b/Sources/Runestone/TextView/Core/LayoutManager.swift @@ -1,29 +1,21 @@ -// swiftlint:disable file_length +import CoreGraphics +import Foundation +import QuartzCore +#if os(iOS) import UIKit +#endif +// 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? { - didSet { - if gutterParentView != oldValue { - setupViewHierarchy() - } - } - } - weak var textInputView: UIView? { - didSet { - if textInputView != oldValue { - setupViewHierarchy() - } - } - } var lineManager: LineManager var stringView: StringView var scrollViewWidth: CGFloat = 0 + var verticalScrollerWidth: CGFloat = 0 var viewport: CGRect = .zero var languageMode: InternalLanguageMode { didSet { @@ -82,8 +74,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 { @@ -94,7 +86,11 @@ final class LayoutManager { var lineHeightMultiplier: CGFloat = 1 var constrainingLineWidth: CGFloat { if isLineWrappingEnabled { - return scrollViewWidth - leadingLineSpacing - textContainerInset.right - safeAreaInsets.left - safeAreaInsets.right + return scrollViewWidth + - 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. @@ -110,23 +106,28 @@ final class LayoutManager { } // MARK: - Views - let gutterContainerView = UIView() + let linesContainerView = FlippedView() + let lineSelectionBackgroundView = FlippedView() + let gutterContainerView = FlippedView() private var lineFragmentViewReuseQueue = ViewReuseQueue() private var lineNumberLabelReuseQueue = ViewReuseQueue() private var visibleLineIDs: Set = [] - private let linesContainerView = UIView() private let gutterBackgroundView = GutterBackgroundView() - private let lineNumbersContainerView = UIView() - private let gutterSelectionBackgroundView = UIView() - private let lineSelectionBackgroundView = UIView() + private let gutterSelectionBackgroundView = FlippedView() + 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 @@ -137,8 +138,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 @@ -147,16 +146,16 @@ 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, + highlightService: HighlightService, + invisibleCharacterConfiguration: InvisibleCharacterConfiguration + ) { self.lineManager = lineManager self.languageMode = languageMode self.stringView = stringView @@ -164,18 +163,23 @@ final class LayoutManager { self.lineControllerStorage = lineControllerStorage self.contentSizeService = contentSizeService self.gutterWidthService = gutterWidthService - 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 + #else + self.gutterBackgroundView.wantsLayer = true + self.gutterSelectionBackgroundView.wantsLayer = true + self.lineSelectionBackgroundView.wantsLayer = true + #endif self.updateShownViews() - let memoryWarningNotificationName = UIApplication.didReceiveMemoryWarningNotification - NotificationCenter.default.addObserver(self, selector: #selector(clearMemory), name: memoryWarningNotificationName, object: nil) + #if os(iOS) + subscribeToMemoryWarningNotification() + #endif + setupViewHierarchy() } func redisplayVisibleLines() { @@ -193,7 +197,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 @@ -227,10 +233,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 + ) } } @@ -250,7 +258,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 - leadingLineSpacing let adjustedYPosition = point.y - textContainerInset.top let adjustedPoint = CGPoint(x: adjustedXPosition, y: adjustedYPosition) @@ -292,8 +300,8 @@ extension LayoutManager { CATransaction.begin() CATransaction.setDisableActions(true) layoutGutter() - layoutLineSelection() layoutLinesInViewport() + layoutLineSelection() updateLineNumberColors() CATransaction.commit() } @@ -305,7 +313,7 @@ extension LayoutManager { func layoutLineSelectionIfNeeded() { if needsLayoutLineSelection { - needsLayoutLineSelection = true + needsLayoutLineSelection = false CATransaction.begin() CATransaction.setDisableActions(false) layoutLineSelection() @@ -314,26 +322,46 @@ 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) - 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? { - 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 { @@ -351,8 +379,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 @@ -413,8 +448,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 } @@ -440,7 +477,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) @@ -480,15 +518,22 @@ 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 { 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 @@ -507,22 +552,26 @@ extension LayoutManager { lineNumberView.textColor = theme.lineNumberColor } } + + // 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 + lineSelectionBackgroundView.backgroundColor = theme.selectedLineBackgroundColor } private func setupViewHierarchy() { // 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 - textInputView?.addSubview(lineSelectionBackgroundView) - textInputView?.addSubview(linesContainerView) - gutterParentView?.addSubview(gutterContainerView) gutterContainerView.addSubview(gutterBackgroundView) gutterContainerView.addSubview(gutterSelectionBackgroundView) gutterContainerView.addSubview(lineNumbersContainerView) @@ -533,7 +582,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 } } @@ -555,8 +604,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..e0afbb41b 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: FlippedView, 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/LineMovementController.swift b/Sources/Runestone/TextView/Core/LineMovementController.swift deleted file mode 100644 index 60638eba5..000000000 --- a/Sources/Runestone/TextView/Core/LineMovementController.swift +++ /dev/null @@ -1,138 +0,0 @@ -import UIKit - -final class LineMovementController { - var lineManager: LineManager - var stringView: StringView - let lineControllerStorage: LineControllerStorage - - init(lineManager: LineManager, stringView: StringView, lineControllerStorage: LineControllerStorage) { - self.lineManager = lineManager - self.stringView = stringView - self.lineControllerStorage = lineControllerStorage - } - - func location(from location: Int, in direction: UITextLayoutDirection, offset: Int) -> Int? { - 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) - @unknown default: - newLocation = nil - } - if let newLocation = newLocation, 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 - } - guard let lineController = lineControllerStorage[line.id] else { - return location - } - let lineLocalLocation = max(min(location - line.location, line.data.totalLength), 0) - guard let lineFragmentNode = lineController.lineFragmentNode(containingCharacterAt: lineLocalLocation) else { - return location - } - let lineFragmentLocalLocation = lineLocalLocation - lineFragmentNode.location - 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 { - 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 shouldn't 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 { - let lineController = lineControllerStorage.getOrCreateLineController(for: line) - return lineController.numberOfLineFragments - } -} diff --git a/Sources/Runestone/TextView/Core/Mac/CaretView.swift b/Sources/Runestone/TextView/Core/Mac/CaretView.swift new file mode 100644 index 000000000..1c54513e8 --- /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 = .label { + 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/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/LineSelectionView.swift b/Sources/Runestone/TextView/Core/Mac/LineSelectionView.swift new file mode 100644 index 000000000..c6b61f6c4 --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/LineSelectionView.swift @@ -0,0 +1,9 @@ +#if os(macOS) +import AppKit + +final class LineSelectionView: NSView, ReusableView { + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } +} +#endif 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..2202aac7e --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Commands.swift @@ -0,0 +1,196 @@ +#if os(macOS) +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 + } + 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 isEditable else { + return + } + 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 + } + 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?) { + guard isEditable else { + return + } + 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?) { + 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) + } + } + + /// 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?) { + guard isEditable else { + return + } + 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?) { + guard isEditable else { + return + } + 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?) { + guard isEditable else { + return + } + if let undoManager = undoManager, undoManager.canUndo { + undoManager.undo() + } + } + + /// 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() + } + } + + /// 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 + } + 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 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..aa51556ef --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+Find.swift @@ -0,0 +1,99 @@ +#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 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: + showFindPanel(sender) + findNext(sender) + case .previousMatch: + showFindPanel(sender) + findPrevious(sender) + case .replace, .replaceAndFind: + showFindPanel(sender) + case .replaceAll: + showFindPanel(sender) + case .hideFindInterface: + hideFindPanel(sender) + case .setSearchString: + useSelectionForFind(sender) + default: + super.performTextFinderAction(sender) + } + } +} + +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 + removeHighlights(forCategory: .search) + } + } + + /// 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+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..b14dd79a8 --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+KeyboardNavigation.swift @@ -0,0 +1,173 @@ +#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 scrollToBeginningOfDocument(_ sender: Any?) { + textViewController.moveToBeginningOfDocument() + } + + /// 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 scrollToEndOfDocument(_ sender: Any?) { + textViewController.moveToEndOfDocument() + } + + /// 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 new file mode 100644 index 000000000..5bc1702b8 --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac+NSTextInputClient.swift @@ -0,0 +1,95 @@ +#if os(macOS) +import AppKit + +extension TextView: NSTextInputClient { + // swiftlint:disable:next prohibited_super_call + override public func doCommand(by selector: Selector) { + guard isEditable else { + return + } + super.doCommand(by: selector) + } + + /// The current selection range of the text view. + public func selectedRange() -> NSRange { + 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. + /// - 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 + } + let range = replacementRange.location == NSNotFound ? textViewController.rangeForInsertingText : replacementRange + if textViewController.shouldChangeText(in: range, replacementText: string) { + textViewController.replaceText(in: range, with: string) + } + } + + /// 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) {} + + /// 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: 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 { + 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 new file mode 100644 index 000000000..327730ccb --- /dev/null +++ b/Sources/Runestone/TextView/Core/Mac/TextView_Mac.swift @@ -0,0 +1,894 @@ +// swiftlint:disable file_length +#if os(macOS) +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 + } + /// A Boolean value that indicates whether the text view is editable. + public var isEditable: Bool { + get { + 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 { + get { + textViewController.isEditing + } + set { + if newValue != isEditing { + textViewController.isEditing = newValue + updateCaretVisibility() + } + } + } + /// The text that the text view displays. + public var text: String { + get { + textViewController.text + } + set { + textViewController.text = newValue + } + } + /// Colors and fonts to be used by the editor. + public var theme: Theme { + get { + 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. + public var characterPairs: [CharacterPair] { + get { + 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 { + textViewController.characterPairTrailingComponentDeletionMode + } + set { + textViewController.characterPairTrailingComponentDeletionMode = newValue + } + } + /// Enable to show line numbers in the gutter. + public var showLineNumbers: Bool { + get { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + textViewController.softLineBreakSymbol + } + set { + textViewController.softLineBreakSymbol = newValue + } + } + /// The strategy used when indenting text. + public var indentStrategy: IndentStrategy { + get { + textViewController.indentStrategy + } + set { + textViewController.indentStrategy = newValue + } + } + /// The amount of padding before the line numbers inside the gutter. + public var gutterLeadingPadding: CGFloat { + get { + textViewController.gutterLeadingPadding + } + set { + textViewController.gutterLeadingPadding = newValue + } + } + /// The amount of padding after the line numbers inside the gutter. + public var gutterTrailingPadding: CGFloat { + get { + textViewController.gutterTrailingPadding + } + set { + textViewController.gutterTrailingPadding = newValue + } + } + /// The minimum amount of characters to use for width calculation inside the gutter. + public var gutterMinimumCharacterCount: Int { + get { + textViewController.gutterMinimumCharacterCount + } + set { + textViewController.gutterMinimumCharacterCount = newValue + } + } + /// The amount of spacing surrounding the lines. + public var textContainerInset: NSEdgeInsets { + get { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + textViewController.horizontalOverscrollFactor + } + set { + textViewController.horizontalOverscrollFactor = newValue + } + } + /// Ranges in the text to be highlighted. The color defined by the background will be drawen behind the text. + public var highlightedRanges: [HighlightedRange] { + get { + 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 { + 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:)``. + public var lineEndings: LineEnding { + get { + textViewController.lineEndings + } + set { + textViewController.lineEndings = newValue + } + } + /// 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 + } + } + } + /// The color of the selection highlight. + public var selectionHighlightColor: NSColor = .selectedTextBackgroundColor { + didSet { + 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() + } + } + } + /// 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 caretView = CaretView() + private let selectionViewReuseQueue = ViewReuseQueue() + private var isWindowKey = false { + didSet { + if isWindowKey != oldValue { + updateCaretVisibility() + } + } + } + + 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 + } + } + + /// 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 + scrollView.drawsBackground = false + scrollView.documentView = scrollContentView + scrollView.contentView.postsBoundsChangedNotifications = true + scrollContentView.addSubview(textViewController.layoutManager.linesContainerView) + scrollContentView.addSubview(caretView) + scrollView.addSubview(textViewController.layoutManager.gutterContainerView) + addSubview(textViewController.layoutManager.lineSelectionBackgroundView) + addSubview(scrollView) + setNeedsLayout() + setupWindowObservers() + setupScrollViewBoundsDidChangeObserver() + setupMenu() + } + + deinit { + 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 super.becomeFirstResponder() else { + return false + } + needsLayout = true + if shouldBeginEditing { + isEditing = true + editorDelegate?.textViewDidBeginEditing(self) + } + // Notify find controller that this text view is now focused + notifyFindControllerDidBecomeFocused() + 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 super.resignFirstResponder() else { + return false + } + needsLayout = true + if shouldEndEditing { + isEditing = false + editorDelegate?.textViewDidEndEditing(self) + } + return true + } + + /// 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 + textViewController.viewport = CGRect(origin: scrollView.contentOffset, size: frame.size) + textViewController.scrollViewSize = scrollView.frame.size + textViewController.layoutIfNeeded() + textViewController.handleContentSizeUpdateIfNeeded() + textViewController.updateScrollerVisibility() + updateCaretFrame() + updateSelectedRectangles() + } + + /// Perform layout in concert with the constraint-based layout system. + open override func layout() { + super.layout() + textViewController.layoutIfNeeded() + updateCaretFrame() + updateSelectedRectangles() + } + + /// Informs the view that it has been added to a new view hierarchy. + override public func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + 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() + 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. + /// + /// 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) + refreshFindPanelSearch() + // Layout to ensure the selection erctangles and caret as correctly placed. + setNeedsLayout() + layoutIfNeeded() + } + + /// 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) + } + + /// 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) + } + + /// 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) + } + + /// 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. + /// + /// - 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. + public func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + 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 isEditable && NSPasteboard.general.canReadItem(withDataConformingToTypes: [UTType.plainText.identifier]) + } else if menuItem.action == #selector(selectAll(_:)) { + return !text.isEmpty + } else if menuItem.action == #selector(undo(_:)) { + return isEditable && undoManager?.canUndo ?? false + } else if menuItem.action == #selector(redo(_:)) { + return isEditable && undoManager?.canRedo ?? false + } else if menuItem.action == #selector(showFindPanel(_:)) { + return true + } 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(_:)) { + // Enable NSTextFinder actions (bridged to custom find panel on macOS 12+) + return true + } else { + return true + } + } +} + +// MARK: - Window +private extension TextView { + 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 + } +} + +// MARK: - Scrolling +private extension TextView { + private func setupScrollViewBoundsDidChangeObserver() { + 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() + } + + private func scrollToVisibleLocationIfNeeded() { + if isAutomaticScrollEnabled, let newRange = textViewController.selectedRange, newRange.length == 0 { + textViewController.scrollLocationToVisible(newRange.location) + } + } +} + +// 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() + caretView.frame = caretRectFactory.caretRect(at: selectedRange.upperBound, allowMovingCaretToNextLineFragment: true) + } + + private func updateCaretVisibility() { + if isWindowKey && isEditing && selectedRange().length == 0 { + caretView.isHidden = false + caretView.isBlinkingEnabled = true + caretView.delayBlinkIfNeeded() + } else { + caretView.isHidden = true + caretView.isBlinkingEnabled = false + } + } +} + +// 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 = isFirstResponder ? selectionHighlightColor : unemphasizedSelectionHighlightColor + scrollContentView.addSubview(view, positioned: .below, relativeTo: nil) + appearedViewKeys.insert(key) + } + let disappearedViewKeys = Set(selectionViewReuseQueue.visibleViews.keys).subtracting(appearedViewKeys) + selectionViewReuseQueue.enqueueViews(withKeys: disappearedViewKeys) + } +} + +// MARK: - Menu +private extension TextView { + private func setupMenu() { + menu = NSMenu() + 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") + 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") + } +} + +// MARK: - TextViewControllerDelegate +extension TextView: TextViewControllerDelegate { + func textViewControllerDidChangeText(_ textViewController: TextViewController) { + caretView.delayBlinkIfNeeded() + updateCaretFrame() + refreshFindPanelSearch() + editorDelegate?.textViewDidChange(self) + } + + func textViewController(_ textViewController: TextViewController, didChangeSelectedRange selectedRange: NSRange?) { + layoutIfNeeded() + caretView.delayBlinkIfNeeded() + updateCaretVisibility() + 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/TextInputView.swift b/Sources/Runestone/TextView/Core/TextInputView.swift deleted file mode 100644 index a7b2f2c10..000000000 --- a/Sources/Runestone/TextView/Core/TextInputView.swift +++ /dev/null @@ -1,1680 +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. - 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 { - .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/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/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/TextViewController/TextViewController+ContentSize.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift new file mode 100644 index 000000000..3d323a213 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+ContentSize.swift @@ -0,0 +1,62 @@ +import Foundation + +extension TextViewController { + func invalidateContentSizeIfNeeded() { + if scrollView.contentSize != contentSizeService.contentSize { + hasPendingContentSizeUpdate = true + handleContentSizeUpdateIfNeeded() + #if os(macOS) + updateScrollerVisibility() + #endif + } + } + + 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 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 = 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 = scrollView.contentOffset + scrollView.contentSize = contentSizeService.contentSize + scrollView.contentOffset = oldContentOffset + textView.setNeedsLayout() + } + + #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 + 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 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..a3b795b61 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Editing.swift @@ -0,0 +1,195 @@ +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?.nonNegativeLength ?? NSRange(location: stringView.string.length, length: 0) + } + + 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) + 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) { + 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. + let lines = text.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + return lines.joined(separator: lineEndings.symbol) + } + + 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: replacementText) ?? 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 { + 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 + replaceText(in: selectedRange, with: 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 + } + + 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 = oldSelectedRange.capped(to: stringView.string.length) + } + } + + private func textDidChange() { + if isAutomaticScrollEnabled, let newRange = selectedRange, newRange.length == 0 { + scrollLocationToVisible(newRange.location) + } + delegate?.textViewControllerDidChangeText(self) + } + + 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+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..daaf33ce0 --- /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 layoutIfNeeded() { + 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+Navigation.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift new file mode 100644 index 000000000..5f4afcecd --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Navigation.swift @@ -0,0 +1,112 @@ +import Foundation + +extension TextViewController { + func moveLeft() { + navigationService.resetPreviousLineNavigationOperation() + move(by: .character, inDirection: .backward) + } + + func moveRight() { + navigationService.resetPreviousLineNavigationOperation() + move(by: .character, inDirection: .forward) + } + + func moveUp() { + move(by: .line, inDirection: .backward) + } + + func moveDown() { + move(by: .line, inDirection: .forward) + } + + func moveWordLeft() { + navigationService.resetPreviousLineNavigationOperation() + move(by: .word, inDirection: .backward) + } + + func moveWordRight() { + navigationService.resetPreviousLineNavigationOperation() + move(by: .word, inDirection: .forward) + } + + func moveToBeginningOfLine() { + navigationService.resetPreviousLineNavigationOperation() + move(toBoundary: .line, inDirection: .backward) + } + + func moveToEndOfLine() { + navigationService.resetPreviousLineNavigationOperation() + move(toBoundary: .line, inDirection: .forward) + } + + func moveToBeginningOfParagraph() { + navigationService.resetPreviousLineNavigationOperation() + move(toBoundary: .paragraph, inDirection: .backward) + } + + func moveToEndOfParagraph() { + navigationService.resetPreviousLineNavigationOperation() + move(toBoundary: .paragraph, inDirection: .forward) + } + + func moveToBeginningOfDocument() { + navigationService.resetPreviousLineNavigationOperation() + move(toBoundary: .document, inDirection: .backward) + } + + func moveToEndOfDocument() { + navigationService.resetPreviousLineNavigationOperation() + move(toBoundary: .document, inDirection: .forward) + } + + func move(to location: Int) { + navigationService.resetPreviousLineNavigationOperation() + selectedRange = NSRange(location: location, length: 0) + } +} + +private extension TextViewController { + private func move(by granularity: TextGranularity, inDirection direction: TextDirection) { + guard let selectedRange = selectedRange?.nonNegativeLength else { + return + } + 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) + } + } + + private func move(toBoundary boundary: TextBoundary, inDirection direction: TextDirection) { + guard let selectedRange = selectedRange?.nonNegativeLength else { + return + } + let sourceLocation = selectedRange.bound(in: direction) + let location = navigationService.location(moving: sourceLocation, toBoundary: boundary, inDirection: direction) + self.selectedRange = NSRange(location: location, length: 0) + } +} + +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+Scrolling.swift b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift new file mode 100644 index 000000000..e59b1381b --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Scrolling.swift @@ -0,0 +1,81 @@ +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) + scrollView.contentOffset = contentOffsetForScrollingToVisibleRect(rect) + } + + private func caretRect(at location: Int) -> CGRect { + 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. + /// + /// 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 { + 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 + 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 + 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 + scrollPadding + } else if rect.maxX > viewport.maxX { + newContentOffset.x += rect.minX + } + 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 + 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) + 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..05b815f71 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+Selection.swift @@ -0,0 +1,93 @@ +#if os(macOS) +import Foundation + +extension TextViewController { + func moveLeftAndModifySelection() { + navigationService.resetPreviousLineNavigationOperation() + move(by: .character, inDirection: .backward) + } + + func moveRightAndModifySelection() { + navigationService.resetPreviousLineNavigationOperation() + move(by: .character, inDirection: .forward) + } + + func moveUpAndModifySelection() { + move(by: .line, inDirection: .backward) + } + + func moveDownAndModifySelection() { + move(by: .line, inDirection: .forward) + } + + func moveWordLeftAndModifySelection() { + navigationService.resetPreviousLineNavigationOperation() + move(by: .word, inDirection: .backward) + } + + func moveWordRightAndModifySelection() { + navigationService.resetPreviousLineNavigationOperation() + move(by: .word, inDirection: .forward) + } + + func moveToBeginningOfLineAndModifySelection() { + navigationService.resetPreviousLineNavigationOperation() + move(toBoundary: .line, inDirection: .backward) + } + + func moveToEndOfLineAndModifySelection() { + navigationService.resetPreviousLineNavigationOperation() + move(toBoundary: .line, inDirection: .forward) + } + + func moveToBeginningOfParagraphAndModifySelection() { + navigationService.resetPreviousLineNavigationOperation() + move(toBoundary: .paragraph, inDirection: .backward) + } + + func moveToEndOfParagraphAndModifySelection() { + navigationService.resetPreviousLineNavigationOperation() + move(toBoundary: .paragraph, inDirection: .forward) + } + + func moveToBeginningOfDocumentAndModifySelection() { + navigationService.resetPreviousLineNavigationOperation() + move(toBoundary: .document, inDirection: .backward) + } + + func moveToEndOfDocumentAndModifySelection() { + 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) + } + + func selectWord(at location: Int) { + selectedRange = selectionService.rangeBySelectingWord(at: location) + } + + func selectLine(at location: Int) { + selectedRange = selectionService.rangeBySelectingLine(at: location) + } +} + +private extension TextViewController { + 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 direction: TextDirection) { + if let selectedRange { + self.selectedRange = selectionService.range(moving: selectedRange, toBoundary: boundary, inDirection: direction) + } + } +} +#endif 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..1841744f9 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController+UndoRedo.swift @@ -0,0 +1,26 @@ +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 + 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) + #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..773f37ae3 --- /dev/null +++ b/Sources/Runestone/TextView/Core/TextViewController/TextViewController.swift @@ -0,0 +1,804 @@ +// swiftlint:disable file_length +import Combine +import Foundation + +protocol TextViewControllerDelegate: AnyObject { + func textViewControllerDidChangeText(_ textViewController: TextViewController) + func textViewController(_ textViewController: TextViewController, didChangeSelectedRange selectedRange: NSRange?) +} + +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 { + if let textView = _textView { + return textView + } else { + fatalError("The text view has been deallocated or has not been assigned") + } + } + 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? { + get { + _selectedRange + } + set { + if newValue != _selectedRange { + _selectedRange = newValue + delegate?.textViewController(self, didChangeSelectedRange: newValue) + } + } + } + var _selectedRange: NSRange? { + didSet { + if _selectedRange != oldValue { + layoutManager.selectedRange = _selectedRange + layoutManager.setNeedsLayoutLineSelection() + highlightNavigationController.selectedRange = _selectedRange + textView.setNeedsLayout() + } + } + } + var markedRange: NSRange? + var isEditing = false { + didSet { + if isEditing != oldValue { + layoutManager.isEditing = isEditing + } + } + } + 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 + textView.editorDelegate?.textViewDidEndEditing(textView) + } + } + } + var isSelectable = true { + didSet { + if isSelectable != oldValue && !isSelectable && isEditing { + textView.resignFirstResponder() + selectedRange = nil + isEditing = false + textView.editorDelegate?.textViewDidEndEditing(textView) + } + } + } + var viewport: CGRect { + get { + layoutManager.viewport + } + set { + if newValue != layoutManager.viewport { + layoutManager.viewport = newValue + layoutManager.setNeedsLayout() + textView.setNeedsLayout() + } + } + } + var text: String { + get { + 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 = oldSelectedRange.capped(to: stringView.string.length) + #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 scrollViewSize: CGSize = .zero { + didSet { + if scrollViewSize != oldValue { + contentSizeService.scrollViewSize = scrollViewSize + layoutManager.scrollViewWidth = scrollViewSize.width + if isLineWrappingEnabled { + for lineController in lineControllerStorage { + lineController.invalidateTypesetting() + } + } + } + } + } + var safeAreaInsets: MultiPlatformEdgeInsets = .zero { + didSet { + if safeAreaInsets != oldValue { + layoutManager.safeAreaInsets = safeAreaInsets + } + } + } + + private(set) var stringView = StringView() { + didSet { + if stringView !== oldValue { + lineManager.stringView = stringView + lineControllerFactory.stringView = stringView + lineControllerStorage.stringView = stringView + layoutManager.stringView = stringView + indentController.stringView = stringView + navigationService.stringView = stringView + #if os(macOS) + selectionService.stringView = stringView + #endif + } + } + } + let invisibleCharacterConfiguration = InvisibleCharacterConfiguration() + private(set) var lineManager: LineManager { + didSet { + if lineManager !== oldValue { + indentController.lineManager = lineManager + gutterWidthService.lineManager = lineManager + contentSizeService.lineManager = lineManager + highlightService.lineManager = lineManager + navigationService.lineManager = lineManager + #if os(macOS) + selectionService.lineManager = lineManager + #endif + } + } + } + let highlightService: HighlightService + let lineControllerFactory: LineControllerFactory + let lineControllerStorage: LineControllerStorage + 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() + 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 { + if theme !== oldValue { + 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 + gutterWidthService.showLineNumbers = showLineNumbers + layoutManager.showLineNumbers = showLineNumbers + layoutManager.setNeedsLayout() + textView.setNeedsLayout() + #if os(iOS) + textView.inputDelegate?.selectionDidChange(textView) + #endif + } + } + } + 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() + textView.setNeedsLayout() + } + } + } + var showSoftLineBreaks: Bool { + get { + invisibleCharacterConfiguration.showSoftLineBreaks + } + set { + if newValue != invisibleCharacterConfiguration.showSoftLineBreaks { + invisibleCharacterConfiguration.showSoftLineBreaks = newValue + invalidateLines() + layoutManager.setNeedsLayout() + layoutManager.setNeedsDisplayOnLines() + textView.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() + 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 { + layoutManager.textContainerInset + } + set { + if newValue != layoutManager.textContainerInset { + contentSizeService.textContainerInset = newValue + layoutManager.textContainerInset = newValue + layoutManager.setNeedsLayout() + textView.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 { + 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 { + #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() + textView.setNeedsLayout() + } + } + } + } + var pageGuideColumn: Int { + get { + pageGuideController.column + } + set { + if newValue != pageGuideController.column { + pageGuideController.column = newValue + textView.setNeedsLayout() + } + } + } + var verticalOverscrollFactor: CGFloat { + get { + contentSizeService.verticalOverscrollFactor + } + set { + if newValue != contentSizeService.verticalOverscrollFactor { + contentSizeService.verticalOverscrollFactor = newValue + invalidateContentSizeIfNeeded() + } + } + } + var horizontalOverscrollFactor: CGFloat { + get { + contentSizeService.horizontalOverscrollFactor + } + set { + if newValue != contentSizeService.horizontalOverscrollFactor { + contentSizeService.horizontalOverscrollFactor = newValue + invalidateContentSizeIfNeeded() + } + } + } + var lengthOfInitallyLongestLine: Int? { + lineManager.initialLongestLine?.data.totalLength + } + var highlightedRanges: [HighlightedRange] { + get { + highlightService.highlightedRanges + } + set { + if newValue != highlightService.highlightedRanges { + highlightService.highlightedRanges = newValue + layoutManager.setNeedsLayout() + layoutManager.layoutIfNeeded() + highlightNavigationController.highlightedRanges = newValue + } + } + } + + 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 { + return .enabled + } else { + return .disabled + } + } + set { + switch newValue { + case .enabled: + highlightNavigationController.loopRanges = true + case .disabled: + highlightNavigationController.loopRanges = false + } + } + } + var isAutomaticScrollEnabled = true + var hasPendingFullLayout = false + var preserveUndoStackWhenSettingString = false + private(set) var maximumLeadingCharacterPairComponentLength = 0 + + private var estimatedLineHeight: CGFloat { + theme.font.totalLineHeight * lineHeightMultiplier + } + private var cancellables: Set = [] + + // swiftlint:disable:next function_body_length + init(textView: TextView, scrollView: MultiPlatformScrollView) { + _textView = textView + _scrollView = scrollView + 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 + ) + navigationService = NavigationService( + stringView: stringView, + lineManager: lineManager, + lineControllerStorage: lineControllerStorage + ) + #if os(macOS) + selectionService = SelectionService( + stringView: stringView, + lineManager: lineManager, + lineControllerStorage: lineControllerStorage + ) + #endif + layoutManager = LayoutManager( + lineManager: lineManager, + languageMode: languageMode, + stringView: stringView, + lineControllerStorage: lineControllerStorage, + contentSizeService: contentSizeService, + gutterWidthService: gutterWidthService, + highlightService: highlightService, + invisibleCharacterConfiguration: invisibleCharacterConfiguration + ) + indentController = IndentController( + stringView: stringView, + lineManager: lineManager, + languageMode: languageMode, + indentStrategy: indentStrategy, + indentFont: theme.font + ) + layoutManager.delegate = self + 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() + highlightedRanges = [] + 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 = oldSelectedRange.capped(to: stringView.string.length) + 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 { $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 = scrollView.isDragging || scrollView.isDecelerating + if contentOffsetAdjustment != .zero && isScrolling { + let newXOffset = scrollView.contentOffset.x + contentOffsetAdjustment.x + let newYOffset = scrollView.contentOffset.y + contentOffsetAdjustment.y + scrollView.contentOffset = CGPoint(x: newXOffset, y: newYOffset) + } + } +} + +// 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 lineControllerDidInvalidateSize(_ lineController: LineController) { + highlightService.invalidateHighlightedRangeFragments() + 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/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/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 203811b63..c16ae2654 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 { @@ -102,3 +103,4 @@ extension EditMenuController: UIEditMenuInteractionDelegate { } } } +#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 5cab9f466..89257144e 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/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 12f45fab2..0a5a46db5 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 { @@ -271,3 +272,4 @@ private extension CharacterSet { 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 new file mode 100644 index 000000000..440ecac9d --- /dev/null +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS+UITextInput.swift @@ -0,0 +1,527 @@ +// swiftlint:disable file_length +#if os(iOS) +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 + } +} + +// 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)") + } + 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) + } + + /// 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 + } + 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) + } + + /// 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 + let caretOrigin = CGPoint(x: point.x - caretSize.width / 2, y: point.y - caretSize.height / 2) + floatingCaretView.frame = CGRect(origin: caretOrigin, size: caretSize) + } + } + + /// Called at the end of the gesture that the system uses to manipulate the cursor. + func endFloatingCursor() { + insertionPointColor = insertionPointColorBeforeFloatingBegan + updateCaretColor() + floatingCaretView?.removeFromSuperview() + floatingCaretView = nil + editorDelegate?.textViewDidEndFloatingCursor(self) + } +} + +// 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) + } else { + return nil + } + } + + /// 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 { + return + } + guard textViewController.shouldChangeText(in: indexedRange.range.nonNegativeLength, replacementText: preparedText) else { + return + } + 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 + defer { + 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 + // 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.symbol) + } else { + textViewController.replaceText(in: selectedRange, with: preparedText) + } + layoutIfNeeded() + } + + /// Deletes a character from the displayed text. + 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 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: + // 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() + } + } +} + +// MARK: - Selection +public extension TextView { + /// The range of selected text in a document. + 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) + } + } + } + } + + /// 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 [] + } + 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 + ) + return selectionRectFactory.selectionRects(in: indexedRange.range) + } +} + +// 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 { + return IndexedRange(markedRange) + } else { + return nil + } + } + set { + textViewController.markedRange = (newValue as? IndexedRange)?.range.nonNegativeLength + } + } + + /// 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 + } + let markedText = markedText ?? "" + guard textViewController.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 = preferredSelectedRange.capped(to: textViewController.stringView.string.length) + inputDelegate?.selectionDidChange(self) + removeAndAddEditableTextInteraction() + } + + /// Unmarks the currently marked text. + func unmarkText() { + inputDelegate?.selectionWillChange(self) + textViewController.markedRange = nil + inputDelegate?.selectionDidChange(self) + removeAndAddEditableTextInteraction() + } +} + +// 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 + } + let range = NSRange(location: fromIndexedPosition.index, length: toIndexedPosition.index - fromIndexedPosition.index) + 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 + } + let newPosition = indexedPosition.index + offset + guard newPosition >= 0 && newPosition <= textViewController.stringView.string.length else { + return nil + } + 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 + } + didCallPositionFromPositionInDirectionWithOffset = true + let navigationService = textViewController.navigationService + switch direction { + case .right: + let newLocation = navigationService.location(movingFrom: indexedPosition.index, byCharacterCount: offset, inDirection: .forward) + return IndexedPosition(index: newLocation) + case .left: + let newLocation = navigationService.location(movingFrom: indexedPosition.index, byCharacterCount: offset, inDirection: .backward) + return IndexedPosition(index: newLocation) + case .up: + let newLocation = navigationService.location(movingFrom: indexedPosition.index, byLineCount: offset, inDirection: .backward) + return IndexedPosition(index: newLocation) + case .down: + let newLocation = navigationService.location(movingFrom: indexedPosition.index, byLineCount: offset, inDirection: .forward) + return IndexedPosition(index: newLocation) + @unknown default: + return nil + } + } + + /// 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) + // 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 + } + } + + /// 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 + } else { + return 0 + } + } + + /// 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 { + 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 + } + } + + /// 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 { + 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 + } + } + + /// 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)") + } + 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? { + 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. + /// - 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 + } + let index = textViewController.layoutManager.closestIndex(to: point) + let minimumIndex = indexedRange.range.lowerBound + let maximumIndex = indexedRange.range.upperBound + let cappedIndex = min(max(index, minimumIndex), maximumIndex) + 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? { + let index = textViewController.layoutManager.closestIndex(to: point) + let cappedIndex = max(index - 1, 0) + let range = textViewController.stringView.string.customRangeOfComposedCharacterSequence(at: cappedIndex) + return IndexedRange(range) + } +} + +// 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) {} +} + +#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 new file mode 100644 index 000000000..27b298c65 --- /dev/null +++ b/Sources/Runestone/TextView/Core/iOS/TextView_iOS.swift @@ -0,0 +1,1353 @@ +#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 { + /// 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 { + true + } + /// 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 { + textViewController.text + } + set { + textViewController.text = newValue + } + } + /// A Boolean value that indicates whether the text view is editable. + public var isEditable: Bool { + get { + 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 { + 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 + } + } + } + /// Colors and fonts to be used by the editor. + public var theme: Theme { + get { + 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 { + 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 { + textViewController.characterPairTrailingComponentDeletionMode + } + set { + textViewController.characterPairTrailingComponentDeletionMode = newValue + } + } + /// Enable to show line numbers in the gutter. + public var showLineNumbers: Bool { + get { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + textViewController.softLineBreakSymbol + } + set { + textViewController.softLineBreakSymbol = newValue + } + } + /// The strategy used when indenting text. + public var indentStrategy: IndentStrategy { + get { + textViewController.indentStrategy + } + set { + textViewController.indentStrategy = newValue + } + } + /// The amount of padding before the line numbers inside the gutter. + public var gutterLeadingPadding: CGFloat { + get { + textViewController.gutterLeadingPadding + } + set { + textViewController.gutterLeadingPadding = newValue + } + } + /// The amount of padding after the line numbers inside the gutter. + public var gutterTrailingPadding: CGFloat { + get { + textViewController.gutterTrailingPadding + } + set { + textViewController.gutterTrailingPadding = newValue + } + } + /// The minimum amount of characters to use for width calculation inside the gutter. + public var gutterMinimumCharacterCount: Int { + get { + textViewController.gutterMinimumCharacterCount + } + set { + textViewController.gutterMinimumCharacterCount = newValue + } + } + /// The amount of spacing surrounding the lines. + public var textContainerInset: UIEdgeInsets { + get { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + textViewController.horizontalOverscrollFactor + } + set { + textViewController.horizontalOverscrollFactor = newValue + } + } + /// Ranges in the text to be highlighted. The color defined by the background will be drawen behind the text. + public var highlightedRanges: [HighlightedRange] { + get { + 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 { + 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 { + 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. + /// + /// 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 + } + /// 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, scrollView: 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() + 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". + // https://steveshepard.com/blog/adventures-with-uitextinteraction/ + private var textRangeAdjustmentGestureRecognizers: Set = [] + private var previousSelectedRangeDuringGestureHandling: NSRange? + private var isPerformingNonEditableTextInteraction = false + private var shouldBeginEditing: Bool { + guard isEditable else { + return false + } + return editorDelegate?.textViewShouldBeginEditing(self) ?? true + } + private var shouldEndEditing: Bool { + editorDelegate?.textViewShouldEndEditing(self) ?? true + } + + /// Create a new text view. + /// - Parameter frame: The frame rectangle of the text view. + override public init(frame: CGRect) { + super.init(frame: frame) + textViewController.delegate = self + backgroundColor = .white + 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 + addSubview(textViewController.layoutManager.lineSelectionBackgroundView) + addSubview(textViewController.layoutManager.linesContainerView) + addSubview(textViewController.layoutManager.gutterContainerView) + } + + /// The initializer has not been implemented. + /// - Parameter coder: Not used. + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Tells the view that its window object changed. + override open func didMoveToWindow() { + super.didMoveToWindow() + textViewController.performFullLayoutIfNeeded() + } + + /// Lays out subviews. + override open func layoutSubviews() { + super.layoutSubviews() + hasDeletedTextWithPendingLayoutSubviews = false + 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. + // This workaround is inspired by a dialog with Alexander Blach (@lextar), developer of Textastic. + if notifyInputDelegateAboutSelectionChangeInLayoutSubviews { + notifyInputDelegateAboutSelectionChangeInLayoutSubviews = false + inputDelegate?.selectionWillChange(self) + inputDelegate?.selectionDidChange(self) + } + if notifyDelegateAboutSelectionChangeInLayoutSubviews { + notifyDelegateAboutSelectionChangeInLayoutSubviews = false + handleTextSelectionChange() + } + 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. + override open func safeAreaInsetsDidChange() { + super.safeAreaInsetsDidChange() + textViewController.safeAreaInsets = safeAreaInsets + layoutIfNeeded() + } + + /// Asks UIKit to make this object the first responder in its window. + @discardableResult + override open func becomeFirstResponder() -> Bool { + 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. + override open 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. + override open func paste(_ sender: Any?) { + if let selectedTextRange = selectedTextRange, let string = UIPasteboard.general.string { + inputDelegate?.selectionWillChange(self) + let preparedText = textViewController.prepareTextForInsertion(string) + replace(selectedTextRange, withText: preparedText) + inputDelegate?.selectionDidChange(self) + } + } + + /// Cut text to the pasteboard. + /// + /// - Parameter sender: The object calling this method. + override open 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. + override open 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. + override open 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) + } + + /// 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) { + textViewController.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 { + 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 { + notifyInputDelegateAboutSelectionChangeInLayoutSubviews = true + return 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() { + 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) + } + + /// 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() + } + + /// 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) { + 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. + public func text(in range: NSRange) -> String? { + textViewController.text(in: range) + } + + /// Called when the iOS interface environment changes. + override open 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. + override open 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 { + 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. + 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) + } + } +} + +extension TextView { + var viewHierarchyContainsCaret: Bool { + textSelectionView?.subviews.count == 1 + } + var textSelectionView: UIView? { + if let klass = NSClassFromString("UITextSelectionView") { + return subviews.first { $0.isKind(of: klass) } + } else { + return nil + } + } + + private func handleTextSelectionChange() { + UIMenuController.shared.hideMenu(from: self) + scrollToVisibleLocationIfNeeded() + 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) + #if compiler(>=5.9) + if #available(iOS 17, *) { + self.sbs_textSelectionDisplayInteraction?.isActivated = true + self.sbs_textSelectionDisplayInteraction?.sbs_enableCursorBlinks() + } + #endif + } + } + } + + 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 { + 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 installEditableInteraction() { + if editableTextInteraction.view == nil { + 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 + } + } + + 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 + 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) + } + } + + 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: + textViewController.moveUp() + unmarkText() + case .keyboardRightArrow: + textViewController.moveRight() + unmarkText() + case .keyboardDownArrow: + textViewController.moveDown() + unmarkText() + case .keyboardLeftArrow: + textViewController.moveLeft() + unmarkText() + case .keyboardEscape: + unmarkText() + default: + break + } + } + + private func scrollToVisibleLocationIfNeeded() { + if isAutomaticScrollEnabled, let newRange = textViewController.selectedRange, newRange.length == 0 { + textViewController.scrollLocationToVisible(newRange.location) + } + } +} + +// MARK: - TextViewControllerDelegate +extension TextView: TextViewControllerDelegate { + func textViewControllerDidChangeText(_ textViewController: TextViewController) { + editorDelegate?.textViewDidChange(self) + } + + func textViewController(_ textViewController: TextViewController, didChangeSelectedRange selectedRange: NSRange?) { + UIMenuController.shared.hideMenu(from: self) + scrollToVisibleLocationIfNeeded() + editorDelegate?.textViewDidChangeSelection(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 && shouldBeginEditing + } 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? + ) { + scrollToVisibleLocationIfNeeded() + } +} + +// 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 { + 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) { + replaceTextInSelectedHighlightedRange() + } + + func editMenuController(_ controller: EditMenuController, canReplaceTextIn highlightedRange: HighlightedRange) -> Bool { + 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 showMenuAfterNavigatingToHighlightedRange { + editMenuController.presentEditMenu(from: self, forTextIn: range) + } + switch highlightNavigationRange.loopMode { + case .previousGoesToLast: + editorDelegate?.textViewDidLoopToLastHighlightedRange(self) + case .nextGoesToFirst: + editorDelegate?.textViewDidLoopToFirstHighlightedRange(self) + case .disabled: + break + } + } +} +#endif diff --git a/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift b/Sources/Runestone/TextView/Gutter/GutterBackgroundView.swift index c49079cdb..3d44e2c09 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 { hairlineView.backgroundColor } @@ -17,10 +17,13 @@ final class GutterBackgroundView: UIView { } } - private let hairlineView = UIView() + private let hairlineView = MultiPlatformView() override init(frame: CGRect = .zero) { super.init(frame: .zero) + #if os(macOS) + hairlineView.wantsLayer = true + #endif addSubview(hairlineView) } @@ -28,8 +31,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 406ccd4b9..8500afe77 100644 --- a/Sources/Runestone/TextView/Gutter/LineNumberView.swift +++ b/Sources/Runestone/TextView/Gutter/LineNumberView.swift @@ -1,49 +1,75 @@ +#if os(macOS) +import AppKit +#endif +#if os(iOS) import UIKit +#endif -final class LineNumberView: UIView, ReusableView { - var textColor: UIColor { - get { - titleLabel.textColor - } - set { - titleLabel.textColor = newValue +final class LineNumberView: MultiPlatformView, ReusableView { + var textColor: MultiPlatformColor = .black { + didSet { + if textColor != oldValue { + setNeedsDisplay() + } } } - var font: UIFont { - get { - titleLabel.font - } - set { - titleLabel.font = newValue + var font: MultiPlatformFont = .systemFont(ofSize: 14) { + didSet { + if font != oldValue { + setNeedsDisplay() + } } } var text: String? { - get { - titleLabel.text + didSet { + if text != oldValue { + setNeedsDisplay() + } } - set { - titleLabel.text = newValue + } + override var frame: CGRect { + didSet { + if frame != 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) + 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") } - override func layoutSubviews() { - super.layoutSubviews() - let size = titleLabel.intrinsicContentSize - titleLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: size.height) + #if os(iOS) + override func draw(_ rect: CGRect) { + super.draw(rect) + _drawRect() + } + #else + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + _drawRect() + } + #endif +} + +private extension LineNumberView { + private func _drawRect() { + guard let text else { + return + } + let attributes: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: textColor] + 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) + attributedString.draw(at: offset) } } diff --git a/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift b/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift index 52b992e45..31ddeb8fe 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightNavigationController.swift @@ -1,10 +1,10 @@ import Foundation -import UIKit protocol HighlightNavigationControllerDelegate: AnyObject { func highlightNavigationController( _ controller: HighlightNavigationController, - shouldNavigateTo highlightNavigationRange: HighlightNavigationRange) + shouldNavigateTo highlightNavigationRange: HighlightNavigationRange + ) } struct HighlightNavigationRange { diff --git a/Sources/Runestone/TextView/Highlight/HighlightService.swift b/Sources/Runestone/TextView/Highlight/HighlightService.swift index e9885a080..8d99ff0e6 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 @@ -32,14 +53,21 @@ final class HighlightService { return highlightedLineFragments } } -} -private extension HighlightService { - private func invalidateHighlightedRangeFragments() { + func invalidateHighlightedRangeFragments() { highlightedRangeFragmentsPerLine.removeAll() highlightedRangeFragmentsPerLineFragment.removeAll() highlightedRangeFragmentsPerLine = createHighlightedRangeFragmentsPerLine() } +} + +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 createHighlightedRangeFragmentsPerLine() -> [DocumentLineNodeID: [HighlightedRangeFragment]] { var result: [DocumentLineNodeID: [HighlightedRangeFragment]] = [:] @@ -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 5f1554155..40d237c19 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightedRange.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightedRange.swift @@ -1,4 +1,12 @@ -import UIKit +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 { @@ -7,27 +15,35 @@ 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 + /// 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: UIColor, 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 d72c34502..9611bb727 100644 --- a/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift +++ b/Sources/Runestone/TextView/Highlight/HighlightedRangeFragment.swift @@ -1,28 +1,19 @@ -import UIKit +import Foundation final class HighlightedRangeFragment: Equatable { let range: NSRange let containsStart: Bool let containsEnd: Bool - let color: UIColor + let color: MultiPlatformColor + let textColor: 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, textColor: MultiPlatformColor? = nil, cornerRadius: CGFloat) { self.range = range self.containsStart = containsStart self.containsEnd = containsEnd self.color = color + self.textColor = textColor self.cornerRadius = cornerRadius } } @@ -33,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/Indent/IndentController.swift b/Sources/Runestone/TextView/Indent/IndentController.swift index 22d92dc12..8263fa085 100644 --- a/Sources/Runestone/TextView/Indent/IndentController.swift +++ b/Sources/Runestone/TextView/Indent/IndentController.swift @@ -1,5 +1,7 @@ import Foundation +#if os(iOS) import UIKit +#endif protocol IndentControllerDelegate: AnyObject { func indentController(_ controller: IndentController, shouldInsert text: String, in range: NSRange) @@ -8,11 +10,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 @@ -41,7 +47,13 @@ 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 @@ -111,8 +123,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) 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 a837661ec..3ddfa05f5 100644 --- a/Sources/Runestone/TextView/LineController/LineController.swift +++ b/Sources/Runestone/TextView/LineController/LineController.swift @@ -1,13 +1,19 @@ // 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 protocol LineControllerDelegate: AnyObject { func lineSyntaxHighlighter(for lineController: LineController) -> LineSyntaxHighlighter? - func lineControllerDidInvalidateLineWidthDuringAsyncSyntaxHighlight(_ lineController: LineController) + func lineControllerDidInvalidateSize(_ lineController: LineController) } final class LineController { @@ -134,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] { @@ -283,13 +288,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 { @@ -364,6 +365,7 @@ private extension LineController { updateLineHeight(for: newLineFragments) reapplyLineFragmentToLineFragmentControllers() setNeedsDisplayOnLineFragmentViews() + delegate?.lineControllerDidInvalidateSize(self) } private func reapplyLineFragmentToLineFragmentControllers() { 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/LineFragmentController.swift b/Sources/Runestone/TextView/LineController/LineFragmentController.swift index 5a623b331..e56645e51 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 { renderer.markedTextBackgroundColor } diff --git a/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift b/Sources/Runestone/TextView/LineController/LineFragmentRenderer.swift index 7fbdac409..3b9380114 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] = [] @@ -53,16 +53,24 @@ 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 roundedCorners = highlightedRange.roundedCorners + let roundedPath = CGPath(roundedRect: rect, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) 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.addPath(roundedPath) + context.fillPath() + // Draw non-rounded edges if needed. + if !highlightedRange.containsStart { + 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: endX - cornerRadius, y: 0, width: cornerRadius, height: rect.height) + let endPath = CGPath(rect: endRect, transform: nil) + context.addPath(endPath) context.fillPath() - } else { - context.fill(rect) } } context.restoreGState() @@ -100,10 +108,50 @@ 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 + let fragmentLocalRange = fragment.range + .capped(to: lineFragment.visibleRange) + .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/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() + } +} 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/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/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/StatefulLineNavigationLocationFactory.swift b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift new file mode 100644 index 000000000..3d4981ed7 --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/NavigationLocationFactories/StatefulLineNavigationLocationFactory.swift @@ -0,0 +1,91 @@ +import Foundation + +final class StatefulLineNavigationLocationFactory { + 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 + + private let 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 = 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 + 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 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) + return MoveOperation(location: location, offset: directionedOffset, destinationLocation: destinationLocation) + } +} + +extension StatefulLineNavigationLocationFactory.DirectionedOffset { + static func + (lhs: Self, rhs: Self) -> Self { + Self(rawValue: lhs.rawValue + rhs.rawValue) + } +} 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..01184f23f --- /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: StatefulLineNavigationLocationFactory + + init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { + self.stringView = stringView + self.lineManager = lineManager + self.lineControllerStorage = lineControllerStorage + self.lineNavigationLocationService = StatefulLineNavigationLocationFactory( + 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..7e53d5a92 --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/SelectionService.swift @@ -0,0 +1,240 @@ +#if os(macOS) +import Foundation + +final class SelectionService { + var stringView: StringView + var lineManager: LineManager { + didSet { + if lineManager !== oldValue { + lineNavigationLocationService.lineManager = lineManager + } + } + } + + 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? + + 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 let lineNavigationLocationService: StatefulLineNavigationLocationFactory + + init(stringView: StringView, lineManager: LineManager, lineControllerStorage: LineControllerStorage) { + self.stringView = stringView + self.lineManager = lineManager + self.lineControllerStorage = lineControllerStorage + self.lineNavigationLocationService = StatefulLineNavigationLocationFactory( + 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): + 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 { + lineNavigationLocationService.reset() + if range.length == 0 { + selectionOrigin = range.location + } + 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 + } + + 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) + } + + 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) + 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 { + private func anchoringDirection(moving range: NSRange, inDirection direction: TextDirection) -> TextDirection { + if range.length == 0 { + return direction.opposite + } else if range.upperBound == selectionOrigin { + return .forward + } else { + 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 { + func withLowerBound(_ lowerBound: Int) -> NSRange { + let newLength = upperBound - lowerBound + return NSRange(location: lowerBound, length: newLength) + } + + func withUpperBound(_ upperBound: Int) -> NSRange { + let newLength = upperBound - lowerBound + return NSRange(location: lowerBound, length: newLength) + } +} +#endif diff --git a/Sources/Runestone/TextView/Navigation/StringTokenizer.swift b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift new file mode 100644 index 000000000..76b47d965 --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/StringTokenizer.swift @@ -0,0 +1,255 @@ +import Foundation + +final class StringTokenizer { + var lineManager: LineManager + var stringView: StringView + + private let lineControllerStorage: LineControllerStorage + private var newlineCharacters: [Character] { + [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 boundary: TextBoundary, inDirection direction: TextDirection) -> 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 .document: + return isLocation(location, atDocumentBoundaryInDirection: direction) + } + } + + func location(from location: Int, toBoundary boundary: TextBoundary, inDirection direction: TextDirection) -> 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 .document: + return self.location(toDocumentBoundaryInDirection: direction) + } + } +} + +// MARK: - Lines +private extension StringTokenizer { + private func isLocation(_ location: Int, atLineBoundaryInDirection direction: TextDirection) -> 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 + } + 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 + } + case .backward: + return location == lineLocation + lineFragmentNode.location + } + } + + private func location(from location: Int, toLineBoundaryInDirection direction: TextDirection) -> 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: 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: TextDirection) -> Int? { + switch direction { + case .forward: + if location == stringView.string.length { + return location + } else { + var currentIndex = location + while currentIndex < stringView.string.length { + guard let currentString = stringView.composedSubstring(at: currentIndex) else { + break + } + if currentString.count == 1, let character = currentString.first, newlineCharacters.contains(character) { + break + } + currentIndex += 1 + } + return currentIndex + } + case .backward: + if location == 0 { + return location + } else { + var currentIndex = location - 1 + while currentIndex > 0 { + guard let currentString = stringView.composedSubstring(at: currentIndex) else { + break + } + if currentString.count == 1, let character = currentString.first, newlineCharacters.contains(character) { + currentIndex += 1 + break + } + currentIndex -= 1 + } + return currentIndex + } + } + } +} + +// MARK: - Words +private extension StringTokenizer { + private func isLocation(_ location: Int, atWordBoundaryInDirection direction: TextDirection) -> Bool { + let alphanumerics: CharacterSet = .alphanumerics + switch direction { + case .forward: + if location == 0 { + return false + } else if let previousCharacter = stringView.composedSubstring(at: location - 1) { + if location == stringView.string.length { + 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 + } + } else { + return false + } + case .backward: + if location == stringView.string.length { + return false + } else if let string = stringView.composedSubstring(at: location) { + if location == 0 { + 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 + } + } else { + return false + } + } + } + + 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) + } + 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) && !hasReachedEnd(at: index) { + index = advanceIndex(index) + } + return index + } +} + +// MARK: - Document +private extension StringTokenizer { + private func isLocation(_ location: Int, atDocumentBoundaryInDirection direction: TextDirection) -> Bool { + switch direction { + case .backward: + return location == 0 + case .forward: + return location == stringView.string.length + } + } + + private func location(toDocumentBoundaryInDirection direction: TextDirection) -> Int { + switch direction { + case .backward: + return 0 + case .forward: + return stringView.string.length + } + } +} + +private extension CharacterSet { + func contains(_ character: Character) -> Bool { + 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) + } +} diff --git a/Sources/Runestone/TextView/Navigation/TextBoundary.swift b/Sources/Runestone/TextView/Navigation/TextBoundary.swift new file mode 100644 index 000000000..e55c6ec96 --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/TextBoundary.swift @@ -0,0 +1,6 @@ +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/Navigation/TextGranularity.swift b/Sources/Runestone/TextView/Navigation/TextGranularity.swift new file mode 100644 index 000000000..e3442a873 --- /dev/null +++ b/Sources/Runestone/TextView/Navigation/TextGranularity.swift @@ -0,0 +1,5 @@ +enum TextGranularity { + case character + case line + case word +} 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 ff514c144..b43e46bfb 100644 --- a/Sources/Runestone/TextView/PageGuide/PageGuideView.swift +++ b/Sources/Runestone/TextView/PageGuide/PageGuideView.swift @@ -1,6 +1,11 @@ +#if os(macOS) +import AppKit +#endif +#if os(iOS) import UIKit +#endif -final class PageGuideView: UIView { +final class PageGuideView: MultiPlatformView { var hairlineWidth: CGFloat { didSet { if hairlineWidth != oldValue { @@ -8,7 +13,7 @@ final class PageGuideView: UIView { } } } - var hairlineColor: UIColor? { + var hairlineColor: MultiPlatformColor? { get { hairlineView.backgroundColor } @@ -17,13 +22,19 @@ final class PageGuideView: UIView { } } - private let hairlineView = UIView() + private let hairlineView = MultiPlatformView() override init(frame: CGRect) { - self.hairlineWidth = hairlineLength + #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) } @@ -31,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/Mac/FindController.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift new file mode 100644 index 000000000..a0fabfd14 --- /dev/null +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindController.swift @@ -0,0 +1,366 @@ +#if os(macOS) +import AppKit + +final class FindController: NSObject { + static let shared = FindController() + + weak var textView: TextView? { + didSet { + // Even if no search is active, update replace button state + updateReplaceButtonState() + + guard textView != oldValue else { return } + + 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) + } + } + } + + 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 searchOperation: 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 + searchOperation?.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) + + // 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 }) { + self.searchResultIndex = index + } else { + self.searchResultIndex = 0 + } + } else { + self.searchResultIndex = 0 + } + + self.updateHighlights() + if !searchResults.isEmpty { + self.scrollToCurrentMatch() + } + self.updateMatchCountDisplay() + self.updateReplaceButtonState() + } + } + + searchOperation = 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 } + + guard !searchResults.isEmpty else { + textView.removeHighlights(forCategory: .search) + 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 ..< highlightCount { + let result = searchResults[index] + let isSelected = index == searchResultIndex + if let highlightedRange = textView.theme.highlightedRange(forFoundTextRange: result.range, isSelected: isSelected) { + highlightedRanges.append(highlightedRange) + } + } + + textView.setHighlightedRanges(highlightedRanges, forCategory: .search) + } + + private func clearSearchHighlights() { + textView?.removeHighlights(forCategory: .search) + } + + private func scrollToCurrentMatch() { + guard searchResultIndex < searchResults.count else { return } + let result = searchResults[searchResultIndex] + textView?.scrollRangeToVisible(result.range) + } + + private func updateMatchCountDisplay() { + let current = searchResults.isEmpty ? 0 : searchResultIndex + 1 + let total = searchResults.count + findPanel.updateMatchCount(current: current, total: total) + } +} + +// MARK: - FindPanelDelegate +extension FindController: FindPanelDelegate { + func findPanel(_ panel: FindPanel, didUpdateSearchQuery query: String, options: FindPanel.SearchOptions) { + performSearch(query: query, options: options) + } + + func findPanel(_ panel: FindPanel, didRequestFindNext forward: Bool) { + performFind(forward: forward) + } + + func findPanel(_ panel: FindPanel, didRequestReplace range: NSRange, with text: String) { + guard let textView else { return } + + // Replace the currently selected/highlighted match + guard searchResultIndex < searchResults.count else { return } + let matchRange = searchResults[searchResultIndex].range + + // Check if we can replace + 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 + } + } + + // Perform the replacement + textView.replace(matchRange, withText: text) + + // Re-run search to update results after replacement + if !searchQuery.isEmpty { + performSearch(query: searchQuery, options: searchOptions) + // After re-searching, move to next match if there are still results + if !searchResults.isEmpty { + performFind(forward: true) + } + } + } + + func findPanel(_ panel: FindPanel, didRequestReplaceAll query: String, with text: String, options: FindPanel.SearchOptions) { + guard let textView else { return } + + let matchMethod: SearchQuery.MatchMethod + if options.isRegularExpression { + matchMethod = .regularExpression + } else { + matchMethod = options.matchMethod + } + + let searchQuery = SearchQuery( + text: query, + matchMethod: matchMethod, + isCaseSensitive: options.isCaseSensitive + ) + + let results = textView.search(for: searchQuery, replacingMatchesWith: text) + + // Check with delegate if we can replace + for result in results { + let highlightedRange = HighlightedRange(range: result.range, color: .clear) + if let canReplace = textView.editorDelegate?.textView(textView, canReplaceTextIn: highlightedRange), !canReplace { + return + } + } + + let replacements = results.map { BatchReplaceSet.Replacement(range: $0.range, text: $0.replacementText) } + let batchReplaceSet = BatchReplaceSet(replacements: replacements) + textView.replaceText(in: batchReplaceSet) + + // Clear search results and update display + searchResults = [] + searchResultIndex = 0 + clearSearchHighlights() + panel.updateMatchCount(current: 0, total: 0) + } +} + +// MARK: - NSWindowDelegate +extension FindController: NSWindowDelegate { + func windowWillClose(_ notification: Notification) { + clearSearchHighlights() + } +} + +#endif diff --git a/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift new file mode 100644 index 000000000..562df2b59 --- /dev/null +++ b/Sources/Runestone/TextView/SearchAndReplace/Mac/FindPanel.swift @@ -0,0 +1,349 @@ +#if os(macOS) +import AppKit + +/// Delegate protocol for find panel interactions +protocol FindPanelDelegate: AnyObject { + func findPanel(_ panel: FindPanel, didUpdateSearchQuery query: String, options: FindPanel.SearchOptions) + func findPanel(_ panel: FindPanel, didRequestFindNext: Bool) + func findPanel(_ panel: FindPanel, didRequestReplace range: NSRange, with text: String) + func findPanel(_ panel: FindPanel, didRequestReplaceAll query: String, with text: String, options: FindPanel.SearchOptions) +} + +/// A custom find panel for searching and replacing text in a TextView +final class FindPanel: NSView { + struct SearchOptions { + var isCaseSensitive: Bool = false + var isRegularExpression: Bool = false + var matchMethod: SearchQuery.MatchMethod = .contains + } + + weak var delegate: FindPanelDelegate? + + private let searchField = NSSearchField() + private let replaceField = NSSearchField() + private let replaceButton = NSButton() + private let replaceAllButton = NSButton() + private let matchCountLabel = NSTextField(labelWithString: "") + + private let searchAutosaveName = "findPanel" + + private let navigationControl = NSSegmentedControl() + + private let ignoreCaseCheckbox = NSButton(checkboxWithTitle: "Ignore case", target: nil, action: nil) + private let searchModeLabel = NSTextField() + private let searchModePopup = NSPopUpButton() + + private var searchOptions = SearchOptions() + + // MARK: - Initialization + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + searchField.placeholderString = "Find" + searchField.bezelStyle = .squareBezel + searchField.delegate = self + searchField.target = self + searchField.action = #selector(searchFieldDidChange(_:)) + + searchField.maximumRecents = 10 + searchField.searchMenuTemplate = recentsSearchesMenu() + searchField.recentsAutosaveName = searchAutosaveName + searchField.recentSearches = UserDefaults.standard.array(forKey: searchAutosaveName) as? [String] ?? [] + + replaceField.placeholderString = "Replace" + replaceField.bezelStyle = .squareBezel + replaceField.isHidden = false + + if let cell = replaceField.cell as? NSSearchFieldCell { + let pencilImage = NSImage(systemSymbolName: "pencil", accessibilityDescription: nil) + cell.searchButtonCell?.image = pencilImage + // Enable the search menu to match the width of the search field's button + cell.searchMenuTemplate = NSMenu() + } + + navigationControl.controlSize = .regular + navigationControl.target = self + navigationControl.action = #selector(navigationAction) + navigationControl.font = .preferredFont(forTextStyle: .subheadline) + navigationControl.segmentStyle = .rounded + navigationControl.trackingMode = .momentary + + navigationControl.segmentCount = 2 + + let previousImage = NSImage(systemSymbolName: "chevron.left", accessibilityDescription: nil) + navigationControl.setImage(previousImage, forSegment: 0) + navigationControl.setWidth(40, forSegment: 0) + + let nextImage = NSImage(systemSymbolName: "chevron.right", accessibilityDescription: nil) + navigationControl.setImage(nextImage, forSegment: 1) + navigationControl.setWidth(40, forSegment: 1) + + replaceButton.title = "Replace" + replaceButton.bezelStyle = .rounded + replaceButton.target = self + replaceButton.action = #selector(replace(_:)) + + replaceAllButton.title = "Replace All" + replaceAllButton.bezelStyle = .rounded + replaceAllButton.target = self + replaceAllButton.action = #selector(replaceAll(_:)) + + matchCountLabel.isEditable = false + matchCountLabel.isBordered = false + matchCountLabel.drawsBackground = false + matchCountLabel.font = .monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) + matchCountLabel.textColor = .secondaryLabelColor + matchCountLabel.alignment = .right + + matchCountLabel.setContentHuggingPriority(.init(rawValue: 1), for: .horizontal) + + ignoreCaseCheckbox.target = self + ignoreCaseCheckbox.action = #selector(optionDidChange(_:)) + + searchModeLabel.stringValue = "Mode:" + searchModeLabel.textColor = .labelColor + searchModeLabel.drawsBackground = false + searchModeLabel.isBordered = false + searchModeLabel.isEditable = false + searchModeLabel.isSelectable = false + + searchModePopup.addItem(withTitle: "Contains") + searchModePopup.addItem(withTitle: "Starts With") + searchModePopup.addItem(withTitle: "Ends With") + searchModePopup.addItem(withTitle: "Full Word") + searchModePopup.addItem(withTitle: "Regular Expression") + searchModePopup.target = self + searchModePopup.action = #selector(optionDidChange(_:)) + + // Set default values to match SearchOptions defaults + // isCaseSensitive = false means Ignore Case should be ON (checked) + ignoreCaseCheckbox.state = .on + // matchMethod = .contains means "Contains" (index 0) should be selected + searchModePopup.selectItem(at: 0) + + let optionsStack = NSStackView(views: [ + ignoreCaseCheckbox, + NSView(), + searchModeLabel, + searchModePopup, + ]) + + optionsStack.orientation = .horizontal + optionsStack.spacing = 5 + + let bottomStack = NSStackView(views: [ + replaceAllButton, + replaceButton, + matchCountLabel, + navigationControl, + ]) + + bottomStack.orientation = .horizontal + bottomStack.spacing = 12 + + let stackView = NSStackView(views: [ + searchField, + replaceField, + optionsStack, + bottomStack, + ]) + + stackView.orientation = .vertical + stackView.spacing = 20 + + stackView.setCustomSpacing(10, after: searchField) + + addSubview(stackView) + stackView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: topAnchor, constant: 20), + stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), + stackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20), + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func performKeyEquivalent(with event: NSEvent) -> 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 +extension FindPanel: NSSearchFieldDelegate { + func controlTextDidChange(_ obj: Notification) { + if obj.object as? NSTextField === searchField { + 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 { + 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 diff --git a/Sources/Runestone/TextView/SearchAndReplace/TextFinderClient.swift b/Sources/Runestone/TextView/SearchAndReplace/TextFinderClient.swift new file mode 100644 index 000000000..e69de29bb diff --git a/Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift b/Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift similarity index 92% rename from Sources/Runestone/TextView/SearchAndReplace/UITextSearchingHelper.swift rename to Sources/Runestone/TextView/SearchAndReplace/iOS/UITextSearchingHelper.swift index ff2e28a09..305f6e565 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 { @@ -91,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) { @@ -120,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 @@ -196,3 +200,4 @@ private extension SearchQuery.MatchMethod { } } } +#endif diff --git a/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift b/Sources/Runestone/TextView/SyntaxHighlighting/Internal/TreeSitter/TreeSitterSyntaxHighlightToken.swift index 64cd6cdee..76a421b99 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 { 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 f0bc23781..acc93e72a 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/CaretRectFactory.swift similarity index 73% rename from Sources/Runestone/TextView/TextSelection/CaretRectService.swift rename to Sources/Runestone/TextView/TextSelection/CaretRectFactory.swift index 5fac7727e..38d16da48 100644 --- a/Sources/Runestone/TextView/TextSelection/CaretRectService.swift +++ b/Sources/Runestone/TextView/TextSelection/CaretRectFactory.swift @@ -1,34 +1,30 @@ -import UIKit - -final class CaretRectService { - var stringView: StringView - var lineManager: LineManager - var textContainerInset: UIEdgeInsets = .zero - var showLineNumbers = false +import CoreGraphics +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)! + 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) { @@ -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/SelectionRectService.swift b/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift similarity index 78% rename from Sources/Runestone/TextView/TextSelection/SelectionRectService.swift rename to Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift index 909150e64..a35229f2f 100644 --- a/Sources/Runestone/TextView/TextSelection/SelectionRectService.swift +++ b/Sources/Runestone/TextView/TextSelection/SelectionRectFactory.swift @@ -1,22 +1,28 @@ -import UIKit +import CoreGraphics +import Foundation -final class SelectionRectService { - var lineManager: LineManager - var textContainerInset: UIEdgeInsets = .zero - var lineHeightMultiplier: CGFloat = 1 - - private let contentSizeService: ContentSizeService +final class SelectionRectFactory { + private let lineManager: LineManager 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) { + init( + lineManager: LineManager, + gutterWidthService: GutterWidthService, + 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) -> [TextSelectionRect] { @@ -29,9 +35,9 @@ final class SelectionRectService { 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 fullWidth = max(contentSizeService.contentWidth, contentSizeService.scrollViewWidth) - leadingLineSpacing - textContainerInset.right + 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. let width = selectsLineEnding ? fullWidth - (startCaretRect.minX - leadingLineSpacing) : endCaretRect.maxX - startCaretRect.maxX diff --git a/Sources/Runestone/TextView/TextSelection/TextSelectionRect.swift b/Sources/Runestone/TextView/TextSelection/iOS/TextSelectionRect.swift similarity index 59% rename from Sources/Runestone/TextView/TextSelection/TextSelectionRect.swift rename to Sources/Runestone/TextView/TextSelection/iOS/TextSelectionRect.swift index a7c717059..3098794c6 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 { _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/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..37169dfa3 100644 --- a/Tests/RunestoneTests/StringViewTests.swift +++ b/Tests/RunestoneTests/StringViewTests.swift @@ -25,20 +25,22 @@ 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: 2)) + XCTAssertEqual(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 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/TextInputStringTokenizerTests.swift b/Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift similarity index 95% rename from Tests/RunestoneTests/TextInputStringTokenizerTests.swift rename to Tests/RunestoneTests/iOS/TextInputStringTokenizerTests.swift index 10703ec17..e009d741a 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 @@ -263,17 +264,17 @@ 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) 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 { @@ -281,10 +282,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: textView, + stringView: stringView, + lineManager: lineManager, + lineControllerStorage: lineControllerStorage + ) } } 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 98f799e6e..3804cbef6 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 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 1299b25b3..a207566ef 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? { - scrollViews.children(matching: .textView).element + textViews["RunestoneTextView"] } func disablingTextPersistance() -> Self {