├── Artworks ├── kit.png ├── hero.png └── screenshot.png ├── LitextSamples ├── LitextSampleMac │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Info.plist │ ├── LitextSampleMac.entitlements │ ├── AppDelegate.swift │ ├── ViewController.swift │ └── ControlPanelViewController.swift ├── LitextSamples │ ├── Assets.xcassets │ │ ├── Contents.json │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── LitextSamples.entitlements │ ├── Info.plist │ ├── AppDelegate.swift │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── SceneDelegate.swift │ ├── ControlPanelViewController.swift │ └── ViewController.swift └── LitextSamples.xcodeproj │ ├── project.xcworkspace │ └── contents.xcworkspacedata │ └── project.pbxproj ├── .gitignore ├── Litext.xcworkspace └── contents.xcworkspacedata ├── Sources └── Litext │ ├── Supplement │ ├── Extension │ │ ├── Ext+NSRange.swift │ │ ├── Ext+NSString.swift │ │ └── Ext+NSBezierPath.swift │ ├── LocalizedText.swift │ └── LTXPlatformTypes.swift │ ├── LTXLabel │ ├── Selection │ │ ├── LTXAttributeStringRepresentable.swift │ │ ├── LTXLabel+Select.swift │ │ ├── LTXLabel+Selection.swift │ │ ├── LTXSelectionHandle.swift │ │ └── LTXLabel+SelectionLayer.swift │ ├── TextLayout │ │ ├── LTXLabel+Rect.swift │ │ ├── LTXLabel+Draw.swift │ │ ├── LTXLabel+Layout.swift │ │ └── LTXTextLayout.swift │ ├── LTXLabel+Delegate.swift │ ├── DrawAction │ │ └── LTXLineDrawingAction.swift │ ├── Highlight │ │ ├── LTXHighlightRegion.swift │ │ └── LTXLabel+HighlightRegion.swift │ ├── Attachments │ │ ├── LTXLabel+Attachment.swift │ │ └── LTXAttachment.swift │ ├── Interaction │ │ ├── LTXLabel+Interaction.swift │ │ ├── LTXLabel+Interaction@AppKit.swift │ │ └── LTXLabel+Interaction@UIKit.swift │ └── LTXLabel.swift │ ├── Litext.swift │ └── Resources │ └── Localizable.xcstrings ├── Package.swift ├── LICENSE └── README.md /Artworks/kit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lakr233/Litext/HEAD/Artworks/kit.png -------------------------------------------------------------------------------- /Artworks/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lakr233/Litext/HEAD/Artworks/hero.png -------------------------------------------------------------------------------- /Artworks/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lakr233/Litext/HEAD/Artworks/screenshot.png -------------------------------------------------------------------------------- /LitextSamples/LitextSampleMac/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /LitextSamples/LitextSampleMac/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | *.pbxuser 4 | !default.pbxuser 5 | *.mode1v3 6 | !default.mode1v3 7 | *.mode2v3 8 | !default.mode2v3 9 | *.perspectivev3 10 | !default.perspectivev3 11 | xcuserdata/ 12 | *.moved-aside 13 | *.xccheckout 14 | *.xcuserstate 15 | *.xcscmblueprint 16 | *.build/ 17 | *.swiftpm/ -------------------------------------------------------------------------------- /LitextSamples/LitextSampleMac/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleAllowMixedLocalizations 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Litext.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Sources/Litext/Supplement/Extension/Ext+NSRange.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Ext+NSRange.swift 3 | // Litext 4 | // 5 | // Created by 秋星桥 on 3/26/25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NSRange { 11 | func contains(_ index: Int) -> Bool { 12 | index >= location && index < (location + length) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Selection/LTXAttributeStringRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | public protocol LTXAttributeStringRepresentable { 9 | func attributedStringRepresentation() -> NSAttributedString 10 | } 11 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples/LitextSamples.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.network.client 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /LitextSamples/LitextSampleMac/LitextSampleMac.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/TextLayout/LTXLabel+Rect.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LTXLabel+Rect.swift 3 | // Litext 4 | // 5 | // Created by 秋星桥 on 3/27/25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension LTXLabel { 11 | func convertRectFromTextLayout(_ rect: CGRect, insetForInteraction useInset: Bool) -> CGRect { 12 | var result = rect 13 | result.origin.y = bounds.height - result.origin.y - result.size.height 14 | if useInset { result = result.insetBy(dx: -4, dy: -4) } 15 | return result 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Litext", 8 | defaultLocalization: "en", 9 | platforms: [ 10 | .iOS(.v13), 11 | .macCatalyst(.v13), 12 | .macOS(.v12), 13 | ], 14 | products: [ 15 | .library(name: "Litext", targets: ["Litext"]), 16 | ], 17 | targets: [ 18 | .target(name: "Litext"), 19 | ] 20 | ) 21 | -------------------------------------------------------------------------------- /Sources/Litext/Litext.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Litext.swift 3 | // Litext 4 | // 5 | // Created by 秋星桥 on 3/27/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public let LTXReplacementText = "\u{FFFC}" 11 | public let LTXAttachmentAttributeName = NSAttributedString.Key("LTXAttachment") 12 | public let LTXLineDrawingCallbackName = NSAttributedString.Key("LTXLineDrawingCallback") 13 | 14 | public extension NSAttributedString.Key { 15 | @inline(__always) static let ltxAttachment = LTXAttachmentAttributeName 16 | @inline(__always) static let ltxLineDrawingCallback = LTXLineDrawingCallbackName 17 | } 18 | -------------------------------------------------------------------------------- /LitextSamples/LitextSampleMac/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import Cocoa 7 | 8 | @main 9 | class AppDelegate: NSObject, NSApplicationDelegate { 10 | func applicationDidFinishLaunching(_: Notification) { 11 | // Insert code here to initialize your application 12 | } 13 | 14 | func applicationWillTerminate(_: Notification) { 15 | // Insert code here to tear down your application 16 | } 17 | 18 | func applicationSupportsSecureRestorableState(_: NSApplication) -> Bool { 19 | true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/LTXLabel+Delegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LTXLabel+Delegate.swift 3 | // Litext 4 | // 5 | // Created by 秋星桥 on 7/5/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol LTXLabelDelegate: AnyObject { 11 | func ltxLabelDidTapOnHighlightContent( 12 | _ ltxLabel: LTXLabel, 13 | region: LTXHighlightRegion?, 14 | location: CGPoint 15 | ) 16 | 17 | func ltxLabelSelectionDidChange( 18 | _ ltxLabel: LTXLabel, 19 | selection: NSRange? 20 | ) 21 | 22 | // useful for moving scrollview accordingly to handle selection 23 | func ltxLabelDetectedUserEventMovingAtLocation( 24 | _ ltxLabel: LTXLabel, 25 | location: CGPoint 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/DrawAction/LTXLineDrawingAction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import Foundation 8 | 9 | public class LTXLineDrawingAction: NSObject { 10 | public typealias ActionHandler = (CGContext, CTLine, CGPoint) -> Void 11 | 12 | public var action: ActionHandler 13 | public var performOncePerAttribute: Bool 14 | 15 | public init(action: @escaping ActionHandler) { 16 | self.action = action 17 | performOncePerAttribute = true 18 | super.init() 19 | } 20 | 21 | public init(multilineAction action: @escaping ActionHandler) { 22 | self.action = action 23 | performOncePerAttribute = false 24 | super.init() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Litext/Supplement/LocalizedText.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LocalizedText.swift 3 | // Litext 4 | // 5 | // Created by Lakr233 & Helixform on 2025/2/18. 6 | // Copyright (c) 2025 Litext Team. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// 12 | /// Make sure to add following key to Info.plist 13 | /// 14 | /// **Localized resources can be mixed** -> true 15 | /// 16 | 17 | public enum LocalizedText { 18 | public static let copy = NSLocalizedString("Copy", bundle: .module, comment: "Copy menu item") 19 | public static let selectAll = NSLocalizedString("Select All", bundle: .module, comment: "Select all menu item") 20 | public static let openLink = NSLocalizedString("Open Link", bundle: .module, comment: "Open link menu item") 21 | public static let copyLink = NSLocalizedString("Copy Link", bundle: .module, comment: "Copy link menu item") 22 | } 23 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleAllowMixedLocalizations 6 | 7 | UIApplicationSceneManifest 8 | 9 | UIApplicationSupportsMultipleScenes 10 | 11 | UISceneConfigurations 12 | 13 | UIWindowSceneSessionRoleApplication 14 | 15 | 16 | UISceneConfigurationName 17 | Default Configuration 18 | UISceneDelegateClassName 19 | $(PRODUCT_MODULE_NAME).SceneDelegate 20 | UISceneStoryboardFile 21 | Main 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Highlight/LTXHighlightRegion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import CoreGraphics 7 | import Foundation 8 | 9 | public class LTXHighlightRegion { 10 | public private(set) var rects: [NSValue] = [] 11 | public private(set) var attributes: [NSAttributedString.Key: Any] 12 | public private(set) var stringRange: NSRange 13 | 14 | var associatedObject: AnyObject? 15 | 16 | init(attributes: [NSAttributedString.Key: Any], stringRange: NSRange) { 17 | self.attributes = attributes 18 | self.stringRange = stringRange 19 | } 20 | 21 | func addRect(_ rect: CGRect) { 22 | #if canImport(UIKit) 23 | rects.append(NSValue(cgRect: rect)) 24 | #elseif canImport(AppKit) 25 | rects.append(NSValue(rect: rect)) 26 | #endif 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Litext/Supplement/LTXPlatformTypes.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | @_exported import CoreGraphics 7 | @_exported import CoreText 8 | @_exported import Foundation 9 | 10 | #if canImport(UIKit) 11 | @_exported import UIKit 12 | 13 | public typealias LTXPlatformView = UIView 14 | public typealias LTXPlatformBezierPath = UIBezierPath 15 | 16 | public typealias PlatformColor = UIColor 17 | public typealias PlatformFont = UIFont 18 | public typealias PlatformApplication = UIApplication 19 | #elseif canImport(AppKit) 20 | @_exported import AppKit 21 | 22 | public typealias LTXPlatformView = NSView 23 | public typealias LTXPlatformBezierPath = NSBezierPath 24 | 25 | public typealias PlatformColor = NSColor 26 | public typealias PlatformFont = NSFont 27 | public typealias PlatformApplication = NSApplication 28 | #else 29 | #error("unsupported platform") 30 | #endif 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Litext Team and project authors. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /LitextSamples/LitextSampleMac/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "mac", 5 | "scale" : "1x", 6 | "size" : "16x16" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "2x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "1x", 16 | "size" : "32x32" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "2x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "1x", 26 | "size" : "128x128" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "2x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "1x", 36 | "size" : "256x256" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "2x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "1x", 46 | "size" : "512x512" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "2x", 51 | "size" : "512x512" 52 | } 53 | ], 54 | "info" : { 55 | "author" : "xcode", 56 | "version" : 1 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/Litext/Supplement/Extension/Ext+NSString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Ext+NSString.swift 3 | // Litext 4 | // 5 | // Created by 秋星桥 on 3/26/25. 6 | // 7 | 8 | import Foundation 9 | 10 | extension NSString { 11 | func rangeOfWord(at index: Int) -> NSRange { 12 | let options: NSString.EnumerationOptions = [.byWords, .substringNotRequired] 13 | var resultRange = NSRange(location: NSNotFound, length: 0) 14 | 15 | enumerateSubstrings(in: NSRange(location: 0, length: length), options: options) { _, substringRange, _, stop in 16 | if substringRange.contains(index) { 17 | resultRange = substringRange 18 | stop.pointee = true 19 | } 20 | } 21 | 22 | return resultRange 23 | } 24 | 25 | func rangeOfLine(at index: Int) -> NSRange { 26 | var startIndex = index 27 | while startIndex > 0, character(at: startIndex - 1) != 0x0A { // 0x0A 是换行符 '\n' 28 | startIndex -= 1 29 | } 30 | 31 | var endIndex = index 32 | while endIndex < length, character(at: endIndex) != 0x0A { 33 | endIndex += 1 34 | } 35 | 36 | return NSRange(location: startIndex, length: endIndex - startIndex) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Litext/Resources/Localizable.xcstrings: -------------------------------------------------------------------------------- 1 | { 2 | "sourceLanguage" : "en", 3 | "strings" : { 4 | "Copy" : { 5 | "comment" : "Copy menu item", 6 | "localizations" : { 7 | "zh-Hans" : { 8 | "stringUnit" : { 9 | "state" : "translated", 10 | "value" : "复制" 11 | } 12 | } 13 | } 14 | }, 15 | "Copy Link" : { 16 | "comment" : "Copy link menu item", 17 | "localizations" : { 18 | "zh-Hans" : { 19 | "stringUnit" : { 20 | "state" : "translated", 21 | "value" : "拷贝链接" 22 | } 23 | } 24 | } 25 | }, 26 | "Open Link" : { 27 | "comment" : "Open link menu item", 28 | "localizations" : { 29 | "zh-Hans" : { 30 | "stringUnit" : { 31 | "state" : "translated", 32 | "value" : "打开链接" 33 | } 34 | } 35 | } 36 | }, 37 | "Select All" : { 38 | "comment" : "Select all menu item", 39 | "localizations" : { 40 | "zh-Hans" : { 41 | "stringUnit" : { 42 | "state" : "translated", 43 | "value" : "全选" 44 | } 45 | } 46 | } 47 | } 48 | }, 49 | "version" : "1.0" 50 | } -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/TextLayout/LTXLabel+Draw.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LTXLabel+Draw.swift 3 | // Litext 4 | // 5 | // Created by 秋星桥 on 3/27/25. 6 | // 7 | 8 | import Foundation 9 | 10 | public extension LTXLabel { 11 | #if canImport(UIKit) 12 | override func draw(_: CGRect) { 13 | guard let context = UIGraphicsGetCurrentContext() else { return } 14 | 15 | if flags.needsUpdateHighlightRegions { 16 | textLayout?.updateHighlightRegions(with: context) 17 | highlightRegions = textLayout?.highlightRegions ?? [] 18 | updateAttachmentViews() 19 | flags.needsUpdateHighlightRegions = false 20 | } 21 | 22 | textLayout?.draw(in: context) 23 | } 24 | 25 | #elseif canImport(AppKit) 26 | override func draw(_: NSRect) { 27 | guard let context = NSGraphicsContext.current?.cgContext else { return } 28 | 29 | if flags.needsUpdateHighlightRegions { 30 | textLayout?.updateHighlightRegions(with: context) 31 | highlightRegions = textLayout?.highlightRegions ?? [] 32 | updateAttachmentViews() 33 | flags.needsUpdateHighlightRegions = false 34 | } 35 | 36 | textLayout?.draw(in: context) 37 | } 38 | #endif 39 | } 40 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | @main 9 | final class AppDelegate: UIResponder, UIApplicationDelegate { 10 | func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { 11 | // Override point for customization after application launch. 12 | true 13 | } 14 | 15 | // MARK: UISceneSession Lifecycle 16 | 17 | func application(_: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options _: UIScene.ConnectionOptions) -> UISceneConfiguration { 18 | // Called when a new scene session is being created. 19 | // Use this method to select a configuration to create the new scene with. 20 | UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) 21 | } 22 | 23 | func application(_: UIApplication, didDiscardSceneSessions _: Set) { 24 | // Called when the user discards a scene session. 25 | // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. 26 | // Use this method to release any resources that were specific to the discarded scenes, as they will not return. 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Attachments/LTXLabel+Attachment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | extension LTXLabel { 9 | func isLocationAboveAttachmentView(location: CGPoint) -> Bool { 10 | for view in attachmentViews { 11 | if view.frame.contains(location) { 12 | return true 13 | } 14 | } 15 | return false 16 | } 17 | 18 | func updateAttachmentViews() { 19 | let viewsToRemove = attachmentViews 20 | var newAttachmentViews: Set = [] 21 | 22 | for highlightRegion in highlightRegions { 23 | guard let attachment = highlightRegion.attributes[LTXAttachmentAttributeName] as? LTXAttachment, 24 | let view = attachment.view else { continue } 25 | 26 | if view.superview == self { 27 | newAttachmentViews.insert(view) 28 | } else { 29 | addSubview(view) 30 | newAttachmentViews.insert(view) 31 | } 32 | 33 | #if canImport(UIKit) 34 | let rect = highlightRegion.rects.first!.cgRectValue 35 | #elseif canImport(AppKit) 36 | let rect = highlightRegion.rects.first!.rectValue 37 | #endif 38 | 39 | let convertedRect = convertRectFromTextLayout(rect, insetForInteraction: false) 40 | view.frame = convertedRect 41 | } 42 | 43 | for view in viewsToRemove { 44 | if !newAttachmentViews.contains(view) { 45 | view.removeFromSuperview() 46 | } 47 | } 48 | 49 | attachmentViews = newAttachmentViews 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sources/Litext/Supplement/Extension/Ext+NSBezierPath.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | #if !canImport(UIKit) && canImport(AppKit) 7 | extension NSBezierPath { 8 | func appendPath(_ path: NSBezierPath) { 9 | append(path) 10 | } 11 | 12 | var quartzPath: CGPath { 13 | if #available(macOS 14.0, *) { 14 | return cgPath 15 | } 16 | 17 | let path = CGMutablePath() 18 | var points = [NSPoint](repeating: .zero, count: 3) 19 | 20 | for i in 0 ..< elementCount { 21 | let type = element(at: i, associatedPoints: &points) 22 | 23 | switch type { 24 | case .moveTo: 25 | path.move(to: points[0]) 26 | case .lineTo: 27 | path.addLine(to: points[0]) 28 | case .curveTo: 29 | path.addCurve(to: points[2], control1: points[0], control2: points[1]) 30 | case .closePath: 31 | path.closeSubpath() 32 | case .cubicCurveTo: 33 | assertionFailure() // we do not use cubic curves in Litext 34 | case .quadraticCurveTo: 35 | assertionFailure() // we do not use quadratic curves in Litext 36 | break 37 | @unknown default: 38 | break 39 | } 40 | } 41 | 42 | return path 43 | } 44 | 45 | static func bezierPath(withRoundedRect rect: CGRect, cornerRadius: CGFloat) -> NSBezierPath { 46 | NSBezierPath(roundedRect: rect, xRadius: cornerRadius, yRadius: cornerRadius) 47 | } 48 | } 49 | #endif 50 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Interaction/LTXLabel+Interaction.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LTXLabel+Interaction.swift 3 | // Litext 4 | // 5 | // Created by 秋星桥 on 3/26/25. 6 | // 7 | 8 | import Foundation 9 | 10 | private let kMinimalDistanceToMove: CGFloat = 3.0 11 | private let kMultiClickTimeThreshold: TimeInterval = 0.25 12 | 13 | extension LTXLabel { 14 | func setInteractionStateToBegin(initialLocation: CGPoint) { 15 | interactionState.initialTouchLocation = initialLocation 16 | interactionState.isFirstMove = true 17 | isInteractionInProgress = true 18 | } 19 | 20 | func bumpClickCountIfWithinTimeGap() { 21 | let currentTime = Date().timeIntervalSince1970 22 | let isContinuousClick = currentTime - interactionState.lastClickTime <= kMultiClickTimeThreshold 23 | interactionState.lastClickTime = currentTime 24 | if isContinuousClick { 25 | interactionState.clickCount += 1 26 | } 27 | scheduleContinuousStateReset() 28 | } 29 | 30 | func scheduleContinuousStateReset() { 31 | NSObject.cancelPreviousPerformRequests( 32 | withTarget: self, 33 | selector: #selector(performContinuousStateReset), 34 | object: nil 35 | ) 36 | perform( 37 | #selector(performContinuousStateReset), 38 | with: nil, 39 | afterDelay: kMultiClickTimeThreshold 40 | ) 41 | } 42 | 43 | @objc func performContinuousStateReset() { 44 | interactionState.clickCount = 1 45 | interactionState.lastClickTime = 0 46 | } 47 | 48 | func isTouchReallyMoved(_ point: CGPoint) -> Bool { 49 | let distance = hypot( 50 | point.x - interactionState.initialTouchLocation.x, 51 | point.y - interactionState.initialTouchLocation.y 52 | ) 53 | return distance > 3 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Attachments/LTXAttachment.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import Foundation 8 | 9 | open class LTXAttachment { 10 | open var size: CGSize 11 | open var view: LTXPlatformView? 12 | 13 | private var _runDelegate: CTRunDelegate? 14 | 15 | public init() { 16 | size = .zero 17 | } 18 | 19 | open func attributedStringRepresentation() -> NSAttributedString { 20 | if let view = view as? LTXAttributeStringRepresentable { 21 | return view.attributedStringRepresentation() 22 | } 23 | return NSAttributedString(string: " ") 24 | } 25 | 26 | open var runDelegate: CTRunDelegate { 27 | if _runDelegate == nil { 28 | var callbacks = CTRunDelegateCallbacks( 29 | version: kCTRunDelegateVersion1, 30 | dealloc: { _ in }, 31 | getAscent: { refCon in 32 | let attachment = Unmanaged.fromOpaque(refCon).takeUnretainedValue() 33 | return attachment.size.height * 0.9 34 | }, 35 | getDescent: { refCon in 36 | let attachment = Unmanaged.fromOpaque(refCon).takeUnretainedValue() 37 | return attachment.size.height * 0.1 38 | }, 39 | getWidth: { refCon in 40 | let attachment = Unmanaged.fromOpaque(refCon).takeUnretainedValue() 41 | return attachment.size.width 42 | } 43 | ) 44 | 45 | let unmanagedSelf = Unmanaged.passUnretained(self) 46 | _runDelegate = CTRunDelegateCreate(&callbacks, unmanagedSelf.toOpaque()) 47 | } 48 | 49 | return _runDelegate! 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 9 | var window: UIWindow? 10 | 11 | func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { 12 | // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. 13 | // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. 14 | // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). 15 | guard let _ = (scene as? UIWindowScene) else { return } 16 | } 17 | 18 | func sceneDidDisconnect(_: UIScene) { 19 | // Called as the scene is being released by the system. 20 | // This occurs shortly after the scene enters the background, or when its session is discarded. 21 | // Release any resources associated with this scene that can be re-created the next time the scene connects. 22 | // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). 23 | } 24 | 25 | func sceneDidBecomeActive(_: UIScene) { 26 | // Called when the scene has moved from an inactive state to an active state. 27 | // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. 28 | } 29 | 30 | func sceneWillResignActive(_: UIScene) { 31 | // Called when the scene will move from an active state to an inactive state. 32 | // This may occur due to temporary interruptions (ex. an incoming phone call). 33 | } 34 | 35 | func sceneWillEnterForeground(_: UIScene) { 36 | // Called as the scene transitions from the background to the foreground. 37 | // Use this method to undo the changes made on entering the background. 38 | } 39 | 40 | func sceneDidEnterBackground(_: UIScene) { 41 | // Called as the scene transitions from the foreground to the background. 42 | // Use this method to save data, release shared resources, and store enough scene-specific state information 43 | // to restore the scene back to its current state. 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Hero](./Artworks/hero.png) 2 | 3 | # Litext 4 | 5 | A lightweight rich-text library for UIKit and AppKit platforms. 6 | 7 | **Note: This fork is reimplemented in Swift. While we've maintained API compatibility with the original, 100% compatibility is not guaranteed.** 8 | 9 | ## Features 10 | 11 | - ⚡️ High performance text layout and rendering 12 | - 📎 Native view embedding via attachments 13 | - 🔗 Clickable links support 14 | - 🎨 Custom drawing callbacks 15 | - 📐 Auto layout integration (experimental) 16 | - 📃 Text Selection 17 | 18 | ![Screenshot](./Artworks/screenshot.png) 19 | 20 | ## Supported Platforms 21 | 22 | - iOS 13.0+ 23 | - macOS 12.0+ 24 | 25 | ## Installation 26 | 27 | Add Litext as a dependency in your `Package.swift` file: 28 | 29 | ```swift 30 | dependencies: [ 31 | .package(url: "https://github.com/Helixform/Litext.git", branch: "main") 32 | ] 33 | ``` 34 | 35 | ## Usage 36 | 37 | ### Basic Setup 38 | 39 | ```swift 40 | import Litext 41 | 42 | let label = LTXLabel() 43 | view.addSubview(label) 44 | 45 | // Create and style attributed string 46 | let attributedString = NSMutableAttributedString( 47 | string: "Hello, Litext!", 48 | attributes: [ 49 | .font: NSFont.systemFont(ofSize: 16), 50 | .foregroundColor: NSColor.labelColor 51 | ] 52 | ) 53 | 54 | // Set the attributed text 55 | label.attributedText = attributedString 56 | ``` 57 | 58 | ### Link Handling 59 | 60 | ```swift 61 | let linkString = NSAttributedString( 62 | string: "Visit our website", 63 | attributes: [ 64 | .font: NSFont.systemFont(ofSize: 14), 65 | .link: URL(string: "https://example.com")!, 66 | .foregroundColor: NSColor.linkColor 67 | ] 68 | ) 69 | attributedString.append(linkString) 70 | 71 | // Handle link taps 72 | label.tapHandler = { highlightRegion in 73 | if let url = highlightRegion.attributes[.link] as? URL { 74 | NSWorkspace.shared.open(url) 75 | } 76 | } 77 | ``` 78 | 79 | ### Embedding Native Views 80 | 81 | ```swift 82 | // Create and configure attachment 83 | let attachment = LTXAttachment() 84 | let switchView = NSSwitch() 85 | attachment.view = switchView 86 | attachment.size = switchView.intrinsicContentSize 87 | 88 | // Add attachment to text 89 | attributedString.append( 90 | NSAttributedString( 91 | string: LTXReplacementText, 92 | attributes: [ 93 | .ltxAttachment: attachment, 94 | kCTRunDelegateAttributeName as NSAttributedString.Key: attachment.runDelegate 95 | ] 96 | ) 97 | ) 98 | ``` 99 | 100 | ## License 101 | 102 | This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details. 103 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Selection/LTXLabel+Select.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Litext Team. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import CoreGraphics 7 | import CoreText 8 | import Foundation 9 | import QuartzCore 10 | 11 | public extension LTXLabel { 12 | @objc func selectAllText() { 13 | guard let range = selectAllRange() else { return } 14 | selectionRange = range 15 | } 16 | } 17 | 18 | extension LTXLabel { 19 | func selectWordAtIndex(_ index: Int) { 20 | guard isSelectable, let textLayout else { return } 21 | let attributedString = textLayout.attributedString 22 | guard attributedString.length > 0, index < attributedString.length else { return } 23 | let nsString = attributedString.string as NSString 24 | let range = nsString.rangeOfWord(at: index) 25 | guard range.location != NSNotFound, range.length > 0 else { return } 26 | selectionRange = range 27 | } 28 | 29 | func selectSentenceAtIndex(_ index: Int) { 30 | guard isSelectable, let textLayout else { return } 31 | guard let text = textLayout.attributedString.string as NSString? else { return } 32 | let sentenceDelimiters = CharacterSet(charactersIn: ".!?") 33 | var startIndex = index 34 | while startIndex > 0 { 35 | let prevChar = text.substring(with: NSRange(location: startIndex - 1, length: 1)) 36 | if sentenceDelimiters.contains(prevChar.unicodeScalars.first!) { 37 | break 38 | } 39 | startIndex -= 1 40 | } 41 | var endIndex = index 42 | while endIndex < text.length { 43 | if endIndex < text.length - 1 { 44 | let currentChar = text.substring(with: NSRange(location: endIndex, length: 1)) 45 | if sentenceDelimiters.contains(currentChar.unicodeScalars.first!) { 46 | endIndex += 1 47 | break 48 | } 49 | } 50 | endIndex += 1 51 | } 52 | let range = NSRange(location: startIndex, length: endIndex - startIndex) 53 | selectionRange = range 54 | } 55 | 56 | func selectLineAtIndex(_ index: Int) { 57 | guard isSelectable, let textLayout else { return } 58 | let attributedString = textLayout.attributedString 59 | guard attributedString.length > 0, 60 | index < attributedString.length 61 | else { return } 62 | 63 | let nsString = attributedString.string as NSString 64 | let lineRange = nsString.rangeOfLine(at: index) 65 | 66 | guard lineRange.location != NSNotFound, lineRange.length > 0 else { return } 67 | selectionRange = lineRange 68 | } 69 | 70 | func selectAllRange() -> NSRange? { 71 | guard isSelectable, let textLayout else { return nil } 72 | let attributedString = textLayout.attributedString 73 | guard attributedString.length > 0 else { return nil } 74 | return NSRange(location: 0, length: attributedString.length) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/TextLayout/LTXLabel+Layout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import Foundation 8 | import QuartzCore 9 | 10 | public extension LTXLabel { 11 | func invalidateTextLayout() { 12 | if let selectionRange, 13 | attributedText.length >= selectionRange.location + selectionRange.length 14 | { /* pass */ } else { 15 | clearSelection() 16 | } 17 | 18 | flags.layoutIsDirty = true 19 | #if canImport(UIKit) 20 | setNeedsLayout() 21 | #elseif canImport(AppKit) 22 | needsLayout = true 23 | #endif 24 | invalidateIntrinsicContentSize() 25 | } 26 | 27 | override var intrinsicContentSize: CGSize { 28 | guard let textLayout else { return .zero } 29 | 30 | var constraintSize = CGSize( 31 | width: CGFloat.greatestFiniteMagnitude, 32 | height: CGFloat.greatestFiniteMagnitude 33 | ) 34 | 35 | if preferredMaxLayoutWidth > 0 { 36 | constraintSize.width = preferredMaxLayoutWidth 37 | } else if lastContainerSize.width > 0 { 38 | constraintSize.width = lastContainerSize.width 39 | } 40 | 41 | return textLayout.suggestContainerSize( 42 | withSize: constraintSize 43 | ) 44 | } 45 | 46 | #if canImport(UIKit) 47 | override func layoutSubviews() { 48 | super.layoutSubviews() 49 | 50 | let containerSize = bounds.size 51 | if flags.layoutIsDirty || lastContainerSize != containerSize { 52 | if flags.layoutIsDirty || containerSize.width != lastContainerSize.width { 53 | invalidateIntrinsicContentSize() 54 | } 55 | 56 | lastContainerSize = containerSize 57 | textLayout?.containerSize = containerSize 58 | flags.needsUpdateHighlightRegions = true 59 | flags.layoutIsDirty = false 60 | 61 | updateSelectionLayer() 62 | setNeedsDisplay() 63 | } 64 | } 65 | 66 | override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 67 | super.traitCollectionDidChange(previousTraitCollection) 68 | invalidateTextLayout() 69 | } 70 | 71 | #elseif canImport(AppKit) 72 | override func layout() { 73 | super.layout() 74 | 75 | let containerSize = bounds.size 76 | if flags.layoutIsDirty || lastContainerSize != containerSize { 77 | if flags.layoutIsDirty || containerSize.width != lastContainerSize.width { 78 | invalidateIntrinsicContentSize() 79 | } 80 | 81 | lastContainerSize = containerSize 82 | textLayout?.containerSize = containerSize 83 | flags.needsUpdateHighlightRegions = true 84 | flags.layoutIsDirty = false 85 | 86 | updateSelectionLayer() 87 | needsDisplay = true 88 | } 89 | } 90 | #endif 91 | } 92 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Selection/LTXLabel+Selection.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import CoreGraphics 7 | import CoreText 8 | import Foundation 9 | import QuartzCore 10 | 11 | public extension LTXLabel { 12 | @objc func clearSelection() { 13 | selectionRange = nil 14 | } 15 | 16 | @discardableResult 17 | @objc func copySelectedText() -> NSAttributedString { 18 | guard let selectedText = selectedAttributedText() else { 19 | return .init() 20 | } 21 | 22 | #if canImport(UIKit) 23 | UIPasteboard.general.string = selectedText.string 24 | #elseif canImport(AppKit) 25 | let pasteboard = NSPasteboard.general 26 | pasteboard.clearContents() 27 | pasteboard.setString(selectedText.string, forType: .string) 28 | #endif 29 | 30 | return selectedText.copy() as! NSAttributedString 31 | } 32 | } 33 | 34 | extension LTXLabel { 35 | func updateSelectinoRange(withLocation location: CGPoint) { 36 | guard let textLayout, 37 | let startIndex = textLayout.nearestTextIndex(at: convertPointForTextLayout(interactionState.initialTouchLocation)), 38 | let endIndex = textLayout.nearestTextIndex(at: convertPointForTextLayout(location)) 39 | else { return } 40 | selectionRange = NSRange( 41 | location: min(startIndex, endIndex), 42 | length: abs(endIndex - startIndex) 43 | ) 44 | } 45 | 46 | func nearestTextIndexAtPoint(_ point: CGPoint) -> Int? { 47 | guard let textLayout else { return nil } 48 | return textLayout.nearestTextIndex(at: convertPointForTextLayout(point)) 49 | } 50 | 51 | func textIndexAtPoint(_ point: CGPoint) -> Int? { 52 | guard let textLayout else { return nil } 53 | return textLayout.textIndex(at: convertPointForTextLayout(point)) 54 | } 55 | 56 | func convertPointForTextLayout(_ point: CGPoint) -> CGPoint { 57 | CGPoint(x: point.x, y: bounds.height - point.y) 58 | } 59 | 60 | public func isLocationInSelection(location: CGPoint) -> Bool { 61 | guard let range = selectionRange, 62 | range.length > 0, 63 | let rects = textLayout?.rects(for: range) 64 | else { return false } 65 | return rects.map { 66 | convertRectFromTextLayout($0, insetForInteraction: true) 67 | }.contains { $0.contains(location) } 68 | } 69 | 70 | func selectedAttributedText() -> NSAttributedString? { 71 | guard let textLayout, 72 | let range = selectionRange, 73 | range.location != NSNotFound, 74 | range.length > 0, 75 | textLayout.attributedString.length > 0, 76 | range.location < textLayout.attributedString.length 77 | else { 78 | return nil 79 | } 80 | let maxLen = textLayout.attributedString.length - range.location 81 | 82 | let safeRange = NSRange( 83 | location: range.location, 84 | length: min(range.length, maxLen) 85 | ) 86 | 87 | let selectedText = textLayout 88 | .attributedString 89 | .attributedSubstring(from: safeRange) 90 | 91 | let mutableResult = NSMutableAttributedString(attributedString: selectedText) 92 | mutableResult.enumerateAttribute( 93 | .ltxAttachment, 94 | in: NSRange(location: 0, length: mutableResult.length), 95 | options: [] 96 | ) { value, range, _ in 97 | if let attachment = value as? LTXAttachment { 98 | mutableResult.replaceCharacters( 99 | in: range, 100 | with: attachment.attributedStringRepresentation() 101 | ) 102 | } 103 | } 104 | 105 | return mutableResult 106 | } 107 | 108 | func selectedPlainText() -> String? { 109 | selectedAttributedText()?.string 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Highlight/LTXLabel+HighlightRegion.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | import QuartzCore 8 | 9 | extension LTXLabel { 10 | func activateHighlightRegionAtPoint(_ location: CGPoint) -> Bool { 11 | if let hitHighlightRegion = highlightRegionAtPoint(location) { 12 | addActiveHighlightRegion(hitHighlightRegion) 13 | return true 14 | } 15 | return false 16 | } 17 | 18 | func deactivateHighlightRegion() { 19 | removeActiveHighlightRegion() 20 | } 21 | 22 | func highlightRegionAtPoint(_ point: CGPoint) -> LTXHighlightRegion? { 23 | for region in highlightRegions { 24 | if isHighlightRegion(region, containsPoint: point) { 25 | if region.attributes[.link] == nil { 26 | continue 27 | } 28 | return region 29 | } 30 | } 31 | return nil 32 | } 33 | 34 | func isHighlightRegion(_ highlightRegion: LTXHighlightRegion, containsPoint point: CGPoint) -> Bool { 35 | for boxedRect in highlightRegion.rects { 36 | #if canImport(UIKit) 37 | let rect = boxedRect.cgRectValue 38 | #elseif canImport(AppKit) 39 | let rect = boxedRect.rectValue 40 | #endif 41 | 42 | let convertedRect = convertRectFromTextLayout(rect, insetForInteraction: true) 43 | if convertedRect.contains(point) { 44 | return true 45 | } 46 | } 47 | return false 48 | } 49 | 50 | func addActiveHighlightRegion(_ highlightRegion: LTXHighlightRegion) { 51 | removeActiveHighlightRegion() 52 | 53 | activeHighlightRegion = highlightRegion 54 | 55 | let highlightPath = LTXPlatformBezierPath() 56 | for boxedRect in highlightRegion.rects { 57 | #if canImport(UIKit) 58 | let rect = boxedRect.cgRectValue 59 | #elseif canImport(AppKit) 60 | let rect = boxedRect.rectValue 61 | #endif 62 | 63 | let convertedRect = convertRectFromTextLayout(rect, insetForInteraction: true) 64 | 65 | #if canImport(UIKit) 66 | let subpath = LTXPlatformBezierPath(roundedRect: convertedRect, cornerRadius: 4) 67 | highlightPath.append(subpath) 68 | #elseif canImport(AppKit) 69 | let subpath = LTXPlatformBezierPath.bezierPath(withRoundedRect: convertedRect, cornerRadius: 4) 70 | highlightPath.appendPath(subpath) 71 | #endif 72 | } 73 | 74 | let highlightColor: PlatformColor 75 | if let color = highlightRegion.attributes[.foregroundColor] as? PlatformColor { 76 | highlightColor = color 77 | } else { 78 | #if canImport(UIKit) 79 | highlightColor = .systemBlue 80 | #elseif canImport(AppKit) 81 | highlightColor = .linkColor 82 | #endif 83 | } 84 | 85 | let highlightLayer = CAShapeLayer() 86 | 87 | #if canImport(UIKit) 88 | highlightLayer.path = highlightPath.cgPath 89 | #elseif canImport(AppKit) 90 | highlightLayer.path = highlightPath.quartzPath 91 | #endif 92 | 93 | highlightLayer.fillColor = highlightColor.withAlphaComponent(0.1).cgColor 94 | 95 | #if canImport(UIKit) 96 | layer.addSublayer(highlightLayer) 97 | #elseif canImport(AppKit) 98 | layer?.addSublayer(highlightLayer) 99 | #endif 100 | 101 | highlightRegion.associatedObject = highlightLayer 102 | } 103 | 104 | private func removeActiveHighlightRegion() { 105 | guard let activeHighlightRegion else { return } 106 | 107 | if let highlightLayer = activeHighlightRegion.associatedObject as? CALayer { 108 | highlightLayer.opacity = 0 109 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { 110 | highlightLayer.removeFromSuperlayer() 111 | } 112 | } 113 | 114 | activeHighlightRegion.associatedObject = nil 115 | self.activeHighlightRegion = nil 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Selection/LTXSelectionHandle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Litext Team. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import Foundation 7 | 8 | #if canImport(UIKit) 9 | 10 | protocol LTXSelectionHandleDelegate: AnyObject { 11 | func selectionHandleDidMove(_ type: LTXSelectionHandle.HandleType, toLocationInSuperView point: CGPoint) 12 | } 13 | 14 | public class LTXSelectionHandle: UIView { 15 | static let knobRadius: CGFloat = 12 16 | static let knobExtraResponsiveArea: CGFloat = 20 17 | 18 | public enum HandleType { 19 | case start 20 | case end 21 | } 22 | 23 | public let type: HandleType 24 | 25 | weak var delegate: LTXSelectionHandleDelegate? 26 | 27 | private let knobView: UIView = { 28 | let view = UIView() 29 | view.backgroundColor = .systemBlue 30 | view.layer.cornerRadius = knobRadius / 2 31 | view.layer.shadowColor = UIColor.black.cgColor 32 | view.layer.shadowOffset = CGSize(width: 0, height: 1) 33 | view.layer.shadowOpacity = 0.25 34 | view.layer.shadowRadius = 1.5 35 | return view 36 | }() 37 | 38 | private let stickView: UIView = { 39 | let view = UIView() 40 | view.backgroundColor = .systemBlue 41 | return view 42 | }() 43 | 44 | public init(type: HandleType) { 45 | self.type = type 46 | super.init(frame: .zero) 47 | setupView() 48 | } 49 | 50 | required init?(coder: NSCoder) { 51 | type = .start 52 | super.init(coder: coder) 53 | setupView() 54 | } 55 | 56 | private func setupView() { 57 | backgroundColor = .clear 58 | isUserInteractionEnabled = true 59 | addSubview(stickView) 60 | addSubview(knobView) 61 | let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) 62 | panGesture.cancelsTouchesInView = true 63 | addGestureRecognizer(panGesture) 64 | } 65 | 66 | override public func layoutSubviews() { 67 | super.layoutSubviews() 68 | let stickWidth = 2 69 | stickView.frame = .init( 70 | x: bounds.midX - CGFloat(stickWidth) / 2, 71 | y: bounds.minY, 72 | width: CGFloat(stickWidth), 73 | height: bounds.height 74 | ) 75 | 76 | let knobRadius: CGFloat = knobView.layer.cornerRadius 77 | switch type { 78 | case .start: 79 | knobView.frame = .init( 80 | x: bounds.midX - knobRadius, 81 | y: 0, 82 | width: knobRadius * 2, 83 | height: knobRadius * 2 84 | ) 85 | case .end: 86 | knobView.frame = .init( 87 | x: bounds.midX - knobRadius, 88 | y: bounds.height - knobRadius * 2, 89 | width: knobRadius * 2, 90 | height: knobRadius * 2 91 | ) 92 | } 93 | } 94 | 95 | private var frameAtGestureBegin: CGRect = .zero 96 | 97 | @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { 98 | switch gesture.state { 99 | case .began: 100 | frameAtGestureBegin = frame 101 | fallthrough 102 | case .changed: 103 | let translation = gesture.translation(in: superview) 104 | let newFrame = CGRect( 105 | x: frameAtGestureBegin.origin.x + translation.x, 106 | y: frameAtGestureBegin.origin.y + translation.y, 107 | width: frameAtGestureBegin.width, 108 | height: frameAtGestureBegin.height 109 | ) 110 | delegate?.selectionHandleDidMove(type, toLocationInSuperView: .init(x: newFrame.midX, y: newFrame.midY)) 111 | default: return 112 | } 113 | } 114 | 115 | override public func point(inside point: CGPoint, with _: UIEvent?) -> Bool { 116 | let touchRect = bounds.insetBy( 117 | dx: -Self.knobExtraResponsiveArea, 118 | dy: -Self.knobExtraResponsiveArea 119 | ) 120 | return touchRect.contains(point) 121 | } 122 | } 123 | #endif 124 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/LTXLabel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import CoreFoundation 7 | import CoreText 8 | import Foundation 9 | import QuartzCore 10 | 11 | public class LTXLabel: LTXPlatformView, Identifiable { 12 | public let id: UUID = .init() 13 | 14 | // MARK: - Public Properties 15 | 16 | public var attributedText: NSAttributedString = .init() { 17 | didSet { textLayout = LTXTextLayout(attributedString: attributedText) } 18 | } 19 | 20 | public var preferredMaxLayoutWidth: CGFloat = 0 { 21 | didSet { 22 | if preferredMaxLayoutWidth != oldValue { 23 | invalidateTextLayout() 24 | } 25 | } 26 | } 27 | 28 | override public var frame: CGRect { 29 | get { super.frame } 30 | set { 31 | guard newValue != super.frame else { return } 32 | super.frame = newValue 33 | invalidateTextLayout() 34 | } 35 | } 36 | 37 | public var isSelectable: Bool = false { 38 | didSet { if !isSelectable { clearSelection() } } 39 | } 40 | 41 | public internal(set) var isInteractionInProgress = false 42 | 43 | #if canImport(UIKit) 44 | 45 | #elseif canImport(AppKit) 46 | public var backgroundColor: PlatformColor = .clear { 47 | didSet { 48 | layer?.backgroundColor = backgroundColor.cgColor 49 | } 50 | } 51 | #endif 52 | 53 | public weak var delegate: LTXLabelDelegate? 54 | 55 | // MARK: - Internal Properties 56 | 57 | var textLayout: LTXTextLayout? { 58 | didSet { invalidateTextLayout() } 59 | } 60 | 61 | var attachmentViews: Set = [] 62 | var highlightRegions: [LTXHighlightRegion] = [] 63 | var activeHighlightRegion: LTXHighlightRegion? 64 | var lastContainerSize: CGSize = .zero 65 | 66 | public internal(set) var selectionRange: NSRange? { 67 | didSet { 68 | updateSelectionLayer() 69 | if selectionRange != oldValue { 70 | delegate?.ltxLabelSelectionDidChange(self, selection: selectionRange) 71 | } 72 | } 73 | } 74 | 75 | var selectedLinkForMenuAction: URL? 76 | var selectionLayer: CAShapeLayer? 77 | 78 | #if canImport(UIKit) && !targetEnvironment(macCatalyst) 79 | var selectionHandleStart: LTXSelectionHandle = .init(type: .start) 80 | var selectionHandleEnd: LTXSelectionHandle = .init(type: .end) 81 | #endif 82 | 83 | var interactionState = InteractionState() 84 | var flags = Flags() 85 | 86 | // MARK: - Initialization 87 | 88 | override public init(frame: CGRect) { 89 | super.init(frame: frame) 90 | commonInit() 91 | } 92 | 93 | public required init?(coder: NSCoder) { 94 | super.init(coder: coder) 95 | commonInit() 96 | } 97 | 98 | deinit { 99 | attributedText = .init() 100 | attachmentViews = [] 101 | clearSelection() 102 | deactivateHighlightRegion() 103 | NotificationCenter.default.removeObserver(self) 104 | } 105 | 106 | private func commonInit() { 107 | registerNotificationCenterForSelectionDeduplicate() 108 | 109 | #if canImport(UIKit) 110 | backgroundColor = .clear 111 | installContextMenuInteraction() 112 | installTextPointerInteraction() 113 | 114 | #if targetEnvironment(macCatalyst) 115 | #else 116 | clipsToBounds = false // for selection handle 117 | selectionHandleStart.isHidden = true 118 | selectionHandleStart.delegate = self 119 | addSubview(selectionHandleStart) 120 | selectionHandleEnd.isHidden = true 121 | selectionHandleEnd.delegate = self 122 | addSubview(selectionHandleEnd) 123 | #endif 124 | #elseif canImport(AppKit) 125 | wantsLayer = true 126 | layer?.backgroundColor = .clear 127 | #endif 128 | } 129 | 130 | // MARK: - Platform Specific 131 | 132 | #if !canImport(UIKit) && canImport(AppKit) 133 | override public var isFlipped: Bool { 134 | true 135 | } 136 | #endif 137 | 138 | #if canImport(UIKit) 139 | override public func didMoveToWindow() { 140 | super.didMoveToWindow() 141 | clearSelection() 142 | invalidateTextLayout() 143 | } 144 | 145 | #elseif canImport(AppKit) 146 | override public func viewDidMoveToWindow() { 147 | super.viewDidMoveToWindow() 148 | clearSelection() 149 | invalidateTextLayout() 150 | } 151 | #endif 152 | } 153 | 154 | extension LTXLabel { 155 | struct InteractionState { 156 | var initialTouchLocation: CGPoint = .zero 157 | var clickCount: Int = 1 158 | var lastClickTime: TimeInterval = 0 159 | var isFirstMove: Bool = false 160 | } 161 | 162 | struct Flags { 163 | var layoutIsDirty: Bool = false 164 | var needsUpdateHighlightRegions: Bool = false 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Selection/LTXLabel+SelectionLayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Litext Team. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import CoreGraphics 7 | import CoreText 8 | import Foundation 9 | import QuartzCore 10 | 11 | private let kDeduplicateSelectionNotification = Notification.Name( 12 | rawValue: "LTXLabelDeduplicateSelectionNotification" 13 | ) 14 | 15 | extension LTXLabel { 16 | func updateSelectionLayer() { 17 | selectionLayer?.removeFromSuperlayer() 18 | selectionLayer = nil 19 | 20 | #if canImport(UIKit) && !targetEnvironment(macCatalyst) 21 | selectionHandleStart.isHidden = true 22 | selectionHandleEnd.isHidden = true 23 | #endif 24 | 25 | guard let textLayout, 26 | let range = selectionRange, 27 | range.location != NSNotFound, 28 | range.length > 0 29 | else { 30 | #if canImport(UIKit) && !targetEnvironment(macCatalyst) 31 | hideSelectionMenuController() 32 | #endif 33 | return 34 | } 35 | 36 | let selectionPath = LTXPlatformBezierPath() 37 | let selectionRects = textLayout.rects(for: range) 38 | guard !selectionRects.isEmpty else { 39 | #if canImport(UIKit) && !targetEnvironment(macCatalyst) 40 | hideSelectionMenuController() 41 | #endif 42 | return 43 | } 44 | 45 | createSelectionPath(selectionPath, fromRects: selectionRects) 46 | createSelectionLayer(withPath: selectionPath) 47 | 48 | #if canImport(UIKit) && !targetEnvironment(macCatalyst) 49 | showSelectionMenuController() 50 | #endif 51 | 52 | #if canImport(UIKit) && !targetEnvironment(macCatalyst) 53 | selectionHandleStart.isHidden = false 54 | selectionHandleEnd.isHidden = false 55 | 56 | var beginRect = textLayout.rects( 57 | for: NSRange(location: range.location, length: 1) 58 | ).first ?? .zero 59 | beginRect = convertRectFromTextLayout(beginRect, insetForInteraction: false) 60 | selectionHandleStart.frame = .init( 61 | x: beginRect.minX - LTXSelectionHandle.knobRadius - 1, 62 | y: beginRect.minY - LTXSelectionHandle.knobRadius, 63 | width: LTXSelectionHandle.knobRadius * 2, 64 | height: beginRect.height + LTXSelectionHandle.knobRadius 65 | ) 66 | var endRect = textLayout.rects( 67 | for: NSRange(location: range.location + range.length - 1, length: 1) 68 | ).first ?? .zero 69 | endRect = convertRectFromTextLayout(endRect, insetForInteraction: false) 70 | selectionHandleEnd.frame = .init( 71 | x: endRect.maxX - LTXSelectionHandle.knobRadius + 1, 72 | y: endRect.minY, 73 | width: LTXSelectionHandle.knobRadius * 2, 74 | height: endRect.height + LTXSelectionHandle.knobRadius 75 | ) 76 | #endif 77 | 78 | NotificationCenter.default.post(name: kDeduplicateSelectionNotification, object: self) 79 | } 80 | 81 | func registerNotificationCenterForSelectionDeduplicate() { 82 | NotificationCenter.default.addObserver( 83 | self, 84 | selector: #selector(deduplicateSelection), 85 | name: kDeduplicateSelectionNotification, 86 | object: nil 87 | ) 88 | } 89 | 90 | @objc private func deduplicateSelection(_ notification: Notification) { 91 | guard let object = notification.object as? LTXLabel, object != self else { return } 92 | clearSelection() 93 | } 94 | 95 | private func createSelectionPath(_ selectionPath: LTXPlatformBezierPath, fromRects rects: [CGRect]) { 96 | for rect in rects { 97 | let convertedRect = convertRectFromTextLayout(rect, insetForInteraction: false) 98 | 99 | #if canImport(UIKit) 100 | let subpath = LTXPlatformBezierPath(rect: convertedRect) 101 | selectionPath.append(subpath) 102 | #elseif canImport(AppKit) 103 | let subpath = LTXPlatformBezierPath(rect: convertedRect) 104 | selectionPath.appendPath(subpath) 105 | #endif 106 | } 107 | } 108 | 109 | private func createSelectionLayer(withPath path: LTXPlatformBezierPath) { 110 | let selLayer = CAShapeLayer() 111 | 112 | #if canImport(UIKit) 113 | selLayer.path = path.cgPath 114 | #elseif canImport(AppKit) 115 | selLayer.path = path.quartzPath 116 | #endif 117 | 118 | #if canImport(UIKit) 119 | selLayer.fillColor = UIColor.systemBlue.withAlphaComponent(0.1).cgColor 120 | #elseif canImport(AppKit) 121 | selLayer.fillColor = NSColor.linkColor.withAlphaComponent(0.1).cgColor 122 | #endif 123 | 124 | #if canImport(UIKit) 125 | layer.insertSublayer(selLayer, at: 0) 126 | #elseif canImport(AppKit) 127 | layer?.insertSublayer(selLayer, at: 0) 128 | #endif 129 | 130 | selectionLayer = selLayer 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Interaction/LTXLabel+Interaction@AppKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LTXLabel+Interaction@AppKit.swift 3 | // Litext 4 | // 5 | // Created by 秋星桥 on 3/26/25. 6 | // 7 | 8 | import Foundation 9 | 10 | #if canImport(UIKit) 11 | 12 | #elseif canImport(AppKit) 13 | public extension LTXLabel { 14 | override var acceptsFirstResponder: Bool { 15 | isSelectable 16 | } 17 | 18 | override func performKeyEquivalent(with event: NSEvent) -> Bool { 19 | guard event.modifierFlags.contains(.command) else { 20 | return super.performKeyEquivalent(with: event) 21 | } 22 | let key = event.charactersIgnoringModifiers 23 | 24 | if key == "c", let range = selectionRange, range.length > 0 { 25 | let copiedText = copySelectedText() 26 | if copiedText.length <= 0 { 27 | _ = copyFromSubviewsRecursively() 28 | } 29 | return true 30 | } 31 | 32 | if key == "a", isSelectable { 33 | selectAllText() 34 | return true 35 | } 36 | return false 37 | } 38 | 39 | override func rightMouseDown(with event: NSEvent) { 40 | let location = convert(event.locationInWindow, from: nil) 41 | setInteractionStateToBegin(initialLocation: location) 42 | if handleRightClick(with: event) { return } 43 | super.rightMouseDown(with: event) 44 | } 45 | 46 | override func mouseDown(with event: NSEvent) { 47 | let location = convert(event.locationInWindow, from: nil) 48 | setInteractionStateToBegin(initialLocation: location) 49 | 50 | if isLocationAboveAttachmentView(location: location) { 51 | super.mouseDown(with: event) 52 | return 53 | } 54 | interactionState.isFirstMove = true 55 | 56 | if activateHighlightRegionAtPoint(location) { 57 | return 58 | } 59 | 60 | bumpClickCountIfWithinTimeGap() 61 | if !isSelectable { return } 62 | 63 | if interactionState.clickCount <= 1 { 64 | if isLocationInSelection(location: location) { 65 | } else { 66 | clearSelection() 67 | } 68 | } else if interactionState.clickCount == 2 { 69 | if let index = textIndexAtPoint(location) { 70 | selectWordAtIndex(index) 71 | } 72 | } else { 73 | if let index = textIndexAtPoint(location) { 74 | selectLineAtIndex(index) 75 | } 76 | } 77 | } 78 | 79 | override func mouseDragged(with event: NSEvent) { 80 | let location = convert(event.locationInWindow, from: nil) 81 | 82 | guard isTouchReallyMoved(location) else { return } 83 | defer { self.delegate?.ltxLabelDetectedUserEventMovingAtLocation(self, location: location) } 84 | 85 | deactivateHighlightRegion() 86 | 87 | if interactionState.isFirstMove { 88 | interactionState.isFirstMove = false 89 | selectionRange = nil 90 | } 91 | 92 | if isSelectable { updateSelectinoRange(withLocation: location) } 93 | } 94 | 95 | override func mouseUp(with event: NSEvent) { 96 | isInteractionInProgress = false 97 | defer { deactivateHighlightRegion() } 98 | let location = convert(event.locationInWindow, from: nil) 99 | 100 | for region in highlightRegions { 101 | let rects = region.rects.map { 102 | convertRectFromTextLayout($0.rectValue, insetForInteraction: true) 103 | } 104 | for rect in rects where rect.contains(location) { 105 | self.delegate?.ltxLabelDidTapOnHighlightContent(self, region: region, location: location) 106 | break 107 | } 108 | } 109 | } 110 | 111 | override func hitTest(_ point: NSPoint) -> NSView? { 112 | if !bounds.contains(point) { return nil } 113 | 114 | for view in attachmentViews { 115 | if view.frame.contains(point) { 116 | return super.hitTest(point) 117 | } 118 | } 119 | 120 | if isSelectable || highlightRegionAtPoint(point) != nil { 121 | window?.makeFirstResponder(self) 122 | return self 123 | } 124 | return super.hitTest(point) 125 | } 126 | 127 | override func updateTrackingAreas() { 128 | super.updateTrackingAreas() 129 | 130 | for trackingArea in trackingAreas { 131 | removeTrackingArea(trackingArea) 132 | } 133 | 134 | let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .mouseMoved, .activeInKeyWindow] 135 | let trackingArea = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil) 136 | addTrackingArea(trackingArea) 137 | } 138 | 139 | override func mouseEntered(with event: NSEvent) { 140 | super.mouseEntered(with: event) 141 | let point = convert(event.locationInWindow, from: nil) 142 | updateCursorForPoint(point) 143 | } 144 | 145 | override func mouseExited(with event: NSEvent) { 146 | super.mouseExited(with: event) 147 | resetCursor() 148 | } 149 | 150 | override func mouseMoved(with event: NSEvent) { 151 | super.mouseMoved(with: event) 152 | let point = convert(event.locationInWindow, from: nil) 153 | updateCursorForPoint(point) 154 | } 155 | 156 | private func handleRightClick(with event: NSEvent) -> Bool { 157 | let point = convert(event.locationInWindow, from: nil) 158 | 159 | if isSelectable, let selectionRange, selectionRange.length > 0 { 160 | showContextMenu() 161 | return true 162 | } 163 | 164 | if let hitRegion = highlightRegionAtPoint(point), 165 | let linkURL = hitRegion.attributes[.link] as? URL 166 | { 167 | selectedLinkForMenuAction = linkURL 168 | showLinkContextMenu() 169 | return true 170 | } 171 | 172 | return false 173 | } 174 | 175 | private func showContextMenu() { 176 | let menu = NSMenu() 177 | menu.addItem( 178 | withTitle: LocalizedText.copy, 179 | action: #selector(copyAction(_:)), 180 | keyEquivalent: "c" 181 | ) 182 | 183 | if let event = NSApp.currentEvent { 184 | NSMenu.popUpContextMenu(menu, with: event, for: self) 185 | } 186 | } 187 | 188 | private func showLinkContextMenu() { 189 | let menu = NSMenu() 190 | 191 | menu.addItem( 192 | withTitle: LocalizedText.openLink, 193 | action: #selector(openLink(_:)), 194 | keyEquivalent: "" 195 | ) 196 | 197 | menu.addItem( 198 | withTitle: LocalizedText.copyLink, 199 | action: #selector(copyLink(_:)), 200 | keyEquivalent: "" 201 | ) 202 | 203 | if let event = NSApp.currentEvent { 204 | NSMenu.popUpContextMenu(menu, with: event, for: self) 205 | } 206 | } 207 | 208 | private func updateCursorForPoint(_ point: CGPoint) { 209 | if !isSelectable { 210 | NSCursor.arrow.set() 211 | return 212 | } 213 | 214 | if highlightRegionAtPoint(point) != nil { 215 | NSCursor.pointingHand.set() 216 | return 217 | } 218 | 219 | if let index = textIndexAtPoint(point) { 220 | let range = NSRange(location: index, length: 1) 221 | let rect = textLayout?.rects(for: range).first 222 | if let rect { 223 | let realRect = convertRectFromTextLayout(rect, insetForInteraction: true) 224 | if realRect.contains(point) { 225 | NSCursor.iBeam.set() 226 | return 227 | } 228 | } 229 | } 230 | 231 | resetCursor() 232 | } 233 | 234 | private func resetCursor() { 235 | NSCursor.arrow.set() 236 | } 237 | 238 | @objc private func copyLink(_: Any) { 239 | guard let linkURL = selectedLinkForMenuAction else { return } 240 | 241 | let pasteboard = NSPasteboard.general 242 | pasteboard.clearContents() 243 | pasteboard.setString(linkURL.absoluteString, forType: .string) 244 | } 245 | 246 | @objc private func openLink(_: Any) { 247 | guard let url = selectedLinkForMenuAction else { return } 248 | NSWorkspace.shared.open(url) 249 | } 250 | 251 | @objc func copyAction(_: Any?) { 252 | let copiedText = copySelectedText() 253 | if copiedText.length <= 0 { 254 | _ = copyFromSubviewsRecursively() 255 | } 256 | } 257 | 258 | private func copyFromSubviewsRecursively() -> Bool { 259 | copyFromSubviewsRecursively(in: self) 260 | } 261 | 262 | private func copyFromSubviewsRecursively(in view: NSView) -> Bool { 263 | for subview in view.subviews { 264 | if let ltxLabel = subview as? LTXLabel { 265 | let copiedText = ltxLabel.copySelectedText() 266 | if copiedText.length > 0 { 267 | return true 268 | } 269 | } else { 270 | if copyFromSubviewsRecursively(in: subview) { 271 | return true 272 | } 273 | } 274 | } 275 | return false 276 | } 277 | } 278 | #endif 279 | -------------------------------------------------------------------------------- /LitextSamples/LitextSampleMac/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import AppKit 7 | import Litext 8 | 9 | final class ViewController: NSViewController { 10 | let scrollView = NSScrollView() 11 | let label = LTXLabel() 12 | let controlButton = NSButton(title: "Text Controls", target: nil, action: nil) 13 | 14 | var fontSize: CGFloat = 14 15 | var lineSpacing: CGFloat = 4 16 | var paragraphSpacing: CGFloat = 10 17 | var alignment: NSTextAlignment = .left 18 | var textColor: NSColor = .textColor 19 | var fontWeight: NSFont.Weight = .regular 20 | var fontStyle: FontStyle = .normal 21 | var backgroundColor: NSColor = .clear 22 | 23 | enum FontStyle { 24 | case normal 25 | case italic 26 | case boldItalic 27 | } 28 | 29 | override func viewDidLoad() { 30 | super.viewDidLoad() 31 | 32 | setupLayout() 33 | updateAttributedText() 34 | 35 | label.isSelectable = true 36 | 37 | controlButton.target = self 38 | controlButton.action = #selector(showControlPanel) 39 | } 40 | 41 | func setupLayout() { 42 | scrollView.translatesAutoresizingMaskIntoConstraints = false 43 | scrollView.hasVerticalScroller = true 44 | scrollView.borderType = .noBorder 45 | scrollView.drawsBackground = false 46 | view.addSubview(scrollView) 47 | 48 | label.translatesAutoresizingMaskIntoConstraints = false 49 | scrollView.documentView = label 50 | 51 | controlButton.translatesAutoresizingMaskIntoConstraints = false 52 | controlButton.bezelStyle = .rounded 53 | controlButton.controlSize = .large 54 | view.addSubview(controlButton) 55 | 56 | NSLayoutConstraint.activate([ 57 | scrollView.topAnchor.constraint(equalTo: view.topAnchor, constant: 16), 58 | scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), 59 | scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), 60 | scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -32), 61 | 62 | controlButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), 63 | controlButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -16), 64 | controlButton.widthAnchor.constraint(equalToConstant: 128), 65 | controlButton.heightAnchor.constraint(equalToConstant: 32), 66 | ]) 67 | 68 | label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) 69 | 70 | NSLayoutConstraint.activate([ 71 | label.topAnchor.constraint(equalTo: scrollView.topAnchor), 72 | label.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), 73 | label.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), 74 | label.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), 75 | ]) 76 | } 77 | 78 | override func viewWillLayout() { 79 | super.viewWillLayout() 80 | } 81 | 82 | @objc func showControlPanel() { 83 | let controlPanelVC = ControlPanelViewController() 84 | controlPanelVC.delegate = self 85 | 86 | controlPanelVC.fontSize = fontSize 87 | controlPanelVC.lineSpacing = lineSpacing 88 | controlPanelVC.paragraphSpacing = paragraphSpacing 89 | controlPanelVC.alignment = alignment 90 | controlPanelVC.textColor = textColor 91 | controlPanelVC.fontWeight = fontWeight 92 | controlPanelVC.fontStyle = fontStyle 93 | controlPanelVC.backgroundColor = backgroundColor 94 | 95 | presentAsSheet(controlPanelVC) 96 | } 97 | 98 | func updateAttributedText() { 99 | let attributedString = NSMutableAttributedString() 100 | 101 | attributedString.append( 102 | NSAttributedString( 103 | string: "Hello, Litext!\n\n", 104 | attributes: [ 105 | .font: NSFont.systemFont(ofSize: fontSize + 2, weight: .bold), 106 | .foregroundColor: textColor, 107 | ] 108 | ) 109 | ) 110 | 111 | attributedString.append( 112 | NSAttributedString( 113 | string: "This is a rich text label supporting ", 114 | attributes: [ 115 | .font: createFont(), 116 | .foregroundColor: textColor, 117 | ] 118 | ) 119 | ) 120 | 121 | attributedString.append( 122 | NSAttributedString( 123 | string: "clickable links", 124 | attributes: [ 125 | .font: createFont(), 126 | .link: URL(string: "https://example.com")!, 127 | .foregroundColor: NSColor.linkColor, 128 | ] 129 | ) 130 | ) 131 | 132 | attributedString.append( 133 | NSAttributedString( 134 | string: ", ", 135 | attributes: [ 136 | .font: createFont(), 137 | .foregroundColor: textColor, 138 | ] 139 | ) 140 | ) 141 | 142 | attributedString.append( 143 | NSAttributedString( 144 | string: "underlined text", 145 | attributes: [ 146 | .font: createFont(), 147 | .foregroundColor: textColor, 148 | .underlineStyle: NSUnderlineStyle.single.rawValue, 149 | .underlineColor: NSColor.systemBlue, 150 | ] 151 | ) 152 | ) 153 | 154 | attributedString.append( 155 | NSAttributedString( 156 | string: ", ", 157 | attributes: [ 158 | .font: createFont(), 159 | .foregroundColor: textColor, 160 | ] 161 | ) 162 | ) 163 | 164 | attributedString.append( 165 | NSAttributedString( 166 | string: "strikethrough", 167 | attributes: [ 168 | .font: createFont(), 169 | .foregroundColor: textColor, 170 | .strikethroughStyle: NSUnderlineStyle.single.rawValue, 171 | .strikethroughColor: NSColor.systemRed, 172 | ] 173 | ) 174 | ) 175 | 176 | attributedString.append( 177 | NSAttributedString( 178 | string: " and embedded views:\n\n", 179 | attributes: [ 180 | .font: createFont(), 181 | .foregroundColor: textColor, 182 | ] 183 | ) 184 | ) 185 | 186 | let attachment: LTXAttachment = .init() 187 | let switchView = NSSwitch() 188 | attachment.view = switchView 189 | attachment.size = switchView.intrinsicContentSize 190 | 191 | attributedString.append( 192 | NSAttributedString( 193 | string: LTXReplacementText, 194 | attributes: [ 195 | LTXAttachmentAttributeName: attachment, 196 | kCTRunDelegateAttributeName as NSAttributedString.Key: attachment.runDelegate, 197 | ] 198 | ) 199 | ) 200 | 201 | attributedString.append( 202 | NSAttributedString( 203 | string: " Toggle Switch", 204 | attributes: [ 205 | .font: createFont(), 206 | .foregroundColor: textColor, 207 | ] 208 | ) 209 | ) 210 | 211 | let buttonAttachment = LTXAttachment() 212 | let buttonView = NSButton(title: "Click Me", target: nil, action: nil) 213 | buttonView.bezelStyle = .rounded 214 | buttonView.controlSize = .small 215 | buttonAttachment.view = buttonView 216 | buttonAttachment.size = buttonView.intrinsicContentSize 217 | 218 | attributedString.append( 219 | NSAttributedString( 220 | string: "\n\n", 221 | attributes: [ 222 | .font: createFont(), 223 | .foregroundColor: textColor, 224 | ] 225 | ) 226 | ) 227 | 228 | attributedString.append( 229 | NSAttributedString( 230 | string: LTXReplacementText, 231 | attributes: [ 232 | LTXAttachmentAttributeName: buttonAttachment, 233 | kCTRunDelegateAttributeName as NSAttributedString.Key: buttonAttachment.runDelegate, 234 | ] 235 | ) 236 | ) 237 | 238 | attributedString.append( 239 | NSAttributedString( 240 | string: "\n\n组合样式:", 241 | attributes: [ 242 | .font: createFont(), 243 | .foregroundColor: textColor, 244 | ] 245 | ) 246 | ) 247 | 248 | attributedString.append( 249 | NSAttributedString( 250 | string: "粗体带下划线", 251 | attributes: [ 252 | .font: NSFont.systemFont(ofSize: fontSize, weight: .bold), 253 | .foregroundColor: NSColor.systemPurple, 254 | .underlineStyle: NSUnderlineStyle.single.rawValue, 255 | ] 256 | ) 257 | ) 258 | 259 | attributedString.append( 260 | NSAttributedString( 261 | string: " 和 ", 262 | attributes: [ 263 | .font: createFont(), 264 | .foregroundColor: textColor, 265 | ] 266 | ) 267 | ) 268 | 269 | attributedString.append( 270 | NSAttributedString( 271 | string: "高亮背景文本", 272 | attributes: [ 273 | .font: createFont(), 274 | .foregroundColor: NSColor.black, 275 | .backgroundColor: NSColor.systemYellow, 276 | ] 277 | ) 278 | ) 279 | 280 | attributedString.append( 281 | NSAttributedString( 282 | string: "\n\n中文测试,那只敏捷的棕毛狐狸🦊跳上了那只懒狗🐶。", 283 | attributes: [ 284 | .font: createFont(), 285 | .foregroundColor: textColor, 286 | ] 287 | ) 288 | ) 289 | 290 | attributedString.append( 291 | NSAttributedString( 292 | string: "\n\n这是一段被删除的文字,", 293 | attributes: [ 294 | .font: createFont(), 295 | .foregroundColor: NSColor.systemGray, 296 | .strikethroughStyle: NSUnderlineStyle.single.rawValue, 297 | ] 298 | ) 299 | ) 300 | 301 | attributedString.append( 302 | NSAttributedString( 303 | string: "这是新文字。", 304 | attributes: [ 305 | .font: createFont(), 306 | .foregroundColor: NSColor.systemGreen, 307 | ] 308 | ) 309 | ) 310 | 311 | let paragraphStyle = NSMutableParagraphStyle() 312 | paragraphStyle.lineSpacing = lineSpacing 313 | paragraphStyle.paragraphSpacing = paragraphSpacing 314 | paragraphStyle.alignment = alignment 315 | 316 | attributedString.addAttributes( 317 | [.paragraphStyle: paragraphStyle], 318 | range: NSRange(location: 0, length: attributedString.length) 319 | ) 320 | 321 | label.backgroundColor = backgroundColor 322 | label.attributedText = attributedString 323 | label.delegate = self 324 | } 325 | 326 | private func createFont() -> NSFont { 327 | var font: NSFont 328 | 329 | switch fontStyle { 330 | case .normal: 331 | font = NSFont.systemFont(ofSize: fontSize, weight: fontWeight) 332 | case .italic: 333 | let descriptor = NSFontDescriptor(name: "HelveticaNeue-Italic", size: fontSize) 334 | font = NSFont(descriptor: descriptor, size: fontSize) ?? NSFont.systemFont(ofSize: fontSize) 335 | case .boldItalic: 336 | let descriptor = NSFontDescriptor(name: "HelveticaNeue-BoldItalic", size: fontSize) 337 | font = NSFont(descriptor: descriptor, size: fontSize) ?? NSFont.systemFont(ofSize: fontSize) 338 | } 339 | 340 | return font 341 | } 342 | 343 | func handleLinkTap(_ url: URL) { 344 | NSWorkspace.shared.open(url) 345 | } 346 | } 347 | 348 | extension ViewController: LTXLabelDelegate { 349 | func ltxLabelDidTapOnHighlightContent(_: Litext.LTXLabel, region: Litext.LTXHighlightRegion?, location _: CGPoint) { 350 | if let url = region?.attributes[.link] as? URL { 351 | handleLinkTap(url) 352 | } 353 | } 354 | 355 | func ltxLabelSelectionDidChange(_: Litext.LTXLabel, selection: NSRange?) { 356 | print(String(describing: selection)) 357 | } 358 | 359 | func ltxLabelDetectedUserEventMovingAtLocation(_ ltxLabel: LTXLabel, location: CGPoint) { 360 | print(#function, ltxLabel, location) 361 | } 362 | } 363 | 364 | // MARK: - ControlPanelDelegate 365 | 366 | extension ViewController: ControlPanelDelegate { 367 | func controlPanel(_: ControlPanelViewController, didChangeFontSize fontSize: CGFloat) { 368 | self.fontSize = fontSize 369 | updateAttributedText() 370 | } 371 | 372 | func controlPanel(_: ControlPanelViewController, didChangeFontWeight fontWeight: NSFont.Weight) { 373 | self.fontWeight = fontWeight 374 | updateAttributedText() 375 | } 376 | 377 | func controlPanel(_: ControlPanelViewController, didChangeFontStyle fontStyle: FontStyle) { 378 | self.fontStyle = fontStyle 379 | updateAttributedText() 380 | } 381 | 382 | func controlPanel(_: ControlPanelViewController, didChangeAlignment alignment: NSTextAlignment) { 383 | self.alignment = alignment 384 | updateAttributedText() 385 | } 386 | 387 | func controlPanel(_: ControlPanelViewController, didChangeLineSpacing lineSpacing: CGFloat) { 388 | self.lineSpacing = lineSpacing 389 | updateAttributedText() 390 | } 391 | 392 | func controlPanel(_: ControlPanelViewController, didChangeParagraphSpacing paragraphSpacing: CGFloat) { 393 | self.paragraphSpacing = paragraphSpacing 394 | updateAttributedText() 395 | } 396 | 397 | func controlPanel(_: ControlPanelViewController, didChangeTextColor textColor: NSColor) { 398 | self.textColor = textColor 399 | updateAttributedText() 400 | } 401 | 402 | func controlPanel(_: ControlPanelViewController, didChangeBackgroundColor backgroundColor: NSColor?) { 403 | self.backgroundColor = backgroundColor ?? .clear 404 | updateAttributedText() 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples/ControlPanelViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created for LitextSamples. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import UIKit 7 | 8 | // Define the delegate protocol for communication with the main view controller 9 | protocol ControlPanelDelegate: AnyObject { 10 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeFontSize fontSize: CGFloat) 11 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeFontWeight fontWeight: UIFont.Weight) 12 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeFontStyle fontStyle: ViewController.FontStyle) 13 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeAlignment alignment: NSTextAlignment) 14 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeLineSpacing lineSpacing: CGFloat) 15 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeParagraphSpacing paragraphSpacing: CGFloat) 16 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeTextColor textColor: UIColor) 17 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeBackgroundColor backgroundColor: UIColor?) 18 | } 19 | 20 | class ControlPanelViewController: UIViewController { 21 | // Main components 22 | private let tableView = UITableView(frame: .zero, style: .insetGrouped) 23 | 24 | // Control options 25 | var fontSize: CGFloat = 16 26 | var lineSpacing: CGFloat = 6 27 | var paragraphSpacing: CGFloat = 10 28 | var alignment: NSTextAlignment = .left 29 | var textColor: UIColor = .label 30 | var fontWeight: UIFont.Weight = .regular 31 | var fontStyle: ViewController.FontStyle = .normal 32 | var backgroundColor: UIColor? = nil 33 | 34 | // Control sections and options 35 | let sections = ["Font", "Paragraph", "Color"] 36 | let options: [[String]] = [ 37 | ["Size", "Weight", "Style"], 38 | ["Alignment", "Line Spacing", "Paragraph Spacing"], 39 | ["Text Color", "Background"], 40 | ] 41 | 42 | // Delegate 43 | weak var delegate: ControlPanelDelegate? 44 | 45 | override func viewDidLoad() { 46 | super.viewDidLoad() 47 | 48 | view.backgroundColor = .systemGroupedBackground 49 | setupTableView() 50 | } 51 | 52 | override var keyCommands: [UIKeyCommand]? { 53 | [ 54 | UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(dismiss)), 55 | ] 56 | } 57 | 58 | private func setupTableView() { 59 | tableView.delegate = self 60 | tableView.dataSource = self 61 | tableView.register(UITableViewCell.self, forCellReuseIdentifier: "Cell") 62 | tableView.translatesAutoresizingMaskIntoConstraints = false 63 | view.addSubview(tableView) 64 | 65 | NSLayoutConstraint.activate([ 66 | tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 67 | tableView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), 68 | tableView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), 69 | tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), 70 | ]) 71 | } 72 | } 73 | 74 | // MARK: - UITableViewDelegate & UITableViewDataSource 75 | 76 | extension ControlPanelViewController: UITableViewDelegate, UITableViewDataSource { 77 | func numberOfSections(in _: UITableView) -> Int { 78 | sections.count 79 | } 80 | 81 | func tableView(_: UITableView, numberOfRowsInSection section: Int) -> Int { 82 | options[section].count 83 | } 84 | 85 | func tableView(_: UITableView, titleForHeaderInSection section: Int) -> String? { 86 | sections[section] 87 | } 88 | 89 | func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { 90 | let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) 91 | cell.textLabel?.text = options[indexPath.section][indexPath.row] 92 | cell.accessoryType = .disclosureIndicator 93 | 94 | // Add detail text based on current settings 95 | switch (indexPath.section, indexPath.row) { 96 | case (0, 0): // Font Size 97 | cell.detailTextLabel?.text = "\(Int(fontSize))pt" 98 | case (0, 1): // Font Weight 99 | let weightName = switch fontWeight { 100 | case .regular: "Regular" 101 | case .medium: "Medium" 102 | case .semibold: "Semibold" 103 | case .bold: "Bold" 104 | default: "Regular" 105 | } 106 | cell.detailTextLabel?.text = weightName 107 | case (0, 2): // Font Style 108 | let styleName = switch fontStyle { 109 | case .normal: "Normal" 110 | case .italic: "Italic" 111 | case .boldItalic: "Bold Italic" 112 | } 113 | cell.detailTextLabel?.text = styleName 114 | case (1, 0): // Alignment 115 | let alignmentName = switch alignment { 116 | case .left: "Left" 117 | case .center: "Center" 118 | case .right: "Right" 119 | case .justified: "Justified" 120 | default: "Left" 121 | } 122 | cell.detailTextLabel?.text = alignmentName 123 | case (1, 1): // Line Spacing 124 | cell.detailTextLabel?.text = "\(Int(lineSpacing))pt" 125 | case (1, 2): // Paragraph Spacing 126 | cell.detailTextLabel?.text = "\(Int(paragraphSpacing))pt" 127 | case (2, 0): // Text Color 128 | cell.detailTextLabel?.text = getColorName(textColor) 129 | case (2, 1): // Background Color 130 | cell.detailTextLabel?.text = backgroundColor == nil ? "None" : getColorName(backgroundColor!) 131 | default: 132 | cell.detailTextLabel?.text = nil 133 | } 134 | 135 | return cell 136 | } 137 | 138 | func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { 139 | tableView.deselectRow(at: indexPath, animated: true) 140 | 141 | switch (indexPath.section, indexPath.row) { 142 | case (0, 0): // Font Size 143 | showFontSizeOptions() 144 | case (0, 1): // Font Weight 145 | showFontWeightOptions() 146 | case (0, 2): // Font Style 147 | showFontStyleOptions() 148 | case (1, 0): // Alignment 149 | showAlignmentOptions() 150 | case (1, 1): // Line Spacing 151 | showLineSpacingOptions() 152 | case (1, 2): // Paragraph Spacing 153 | showParagraphSpacingOptions() 154 | case (2, 0): // Text Color 155 | showTextColorOptions() 156 | case (2, 1): // Background Color 157 | showBackgroundColorOptions() 158 | default: 159 | break 160 | } 161 | } 162 | 163 | private func getColorName(_ color: UIColor) -> String { 164 | if color == .label { return "Default" } 165 | if color == .systemRed { return "Red" } 166 | if color == .systemBlue { return "Blue" } 167 | if color == .systemGreen { return "Green" } 168 | if color == .systemPurple { return "Purple" } 169 | if color == .systemOrange { return "Orange" } 170 | return "Custom" 171 | } 172 | 173 | // MARK: - Option Menus 174 | 175 | func showFontSizeOptions() { 176 | let alert = UIAlertController(title: "Font Size", message: nil, preferredStyle: .actionSheet) 177 | 178 | for size in [12, 14, 16, 18, 20, 22, 24] { 179 | alert.addAction(UIAlertAction(title: "\(size)pt", style: .default) { [weak self] _ in 180 | guard let self else { return } 181 | fontSize = CGFloat(size) 182 | delegate?.controlPanel(self, didChangeFontSize: CGFloat(size)) 183 | tableView.reloadData() 184 | }) 185 | } 186 | 187 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) 188 | alert.popoverPresentationController?.sourceView = tableView 189 | present(alert, animated: true) 190 | } 191 | 192 | func showFontWeightOptions() { 193 | let alert = UIAlertController(title: "Font Weight", message: nil, preferredStyle: .actionSheet) 194 | 195 | let weights: [(String, UIFont.Weight)] = [ 196 | ("Regular", .regular), 197 | ("Medium", .medium), 198 | ("Semibold", .semibold), 199 | ("Bold", .bold), 200 | ] 201 | 202 | for (name, weight) in weights { 203 | alert.addAction(UIAlertAction(title: name, style: .default) { [weak self] _ in 204 | guard let self else { return } 205 | fontWeight = weight 206 | delegate?.controlPanel(self, didChangeFontWeight: weight) 207 | tableView.reloadData() 208 | }) 209 | } 210 | 211 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) 212 | alert.popoverPresentationController?.sourceView = tableView 213 | present(alert, animated: true) 214 | } 215 | 216 | func showFontStyleOptions() { 217 | let alert = UIAlertController(title: "Font Style", message: nil, preferredStyle: .actionSheet) 218 | 219 | let styles: [(String, ViewController.FontStyle)] = [ 220 | ("Normal", .normal), 221 | ("Italic", .italic), 222 | ("Bold Italic", .boldItalic), 223 | ] 224 | 225 | for (name, style) in styles { 226 | alert.addAction(UIAlertAction(title: name, style: .default) { [weak self] _ in 227 | guard let self else { return } 228 | fontStyle = style 229 | delegate?.controlPanel(self, didChangeFontStyle: style) 230 | tableView.reloadData() 231 | }) 232 | } 233 | 234 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) 235 | alert.popoverPresentationController?.sourceView = tableView 236 | present(alert, animated: true) 237 | } 238 | 239 | func showAlignmentOptions() { 240 | let alert = UIAlertController(title: "Text Alignment", message: nil, preferredStyle: .actionSheet) 241 | 242 | let alignments: [(String, NSTextAlignment)] = [ 243 | ("Left", .left), 244 | ("Center", .center), 245 | ("Right", .right), 246 | ("Justified", .justified), 247 | ] 248 | 249 | for (name, align) in alignments { 250 | alert.addAction(UIAlertAction(title: name, style: .default) { [weak self] _ in 251 | guard let self else { return } 252 | alignment = align 253 | delegate?.controlPanel(self, didChangeAlignment: align) 254 | tableView.reloadData() 255 | }) 256 | } 257 | 258 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) 259 | alert.popoverPresentationController?.sourceView = tableView 260 | present(alert, animated: true) 261 | } 262 | 263 | func showLineSpacingOptions() { 264 | let alert = UIAlertController(title: "Line Spacing", message: nil, preferredStyle: .actionSheet) 265 | 266 | for spacing in [0, 2, 4, 6, 8, 10, 12] { 267 | alert.addAction(UIAlertAction(title: "\(spacing)pt", style: .default) { [weak self] _ in 268 | guard let self else { return } 269 | lineSpacing = CGFloat(spacing) 270 | delegate?.controlPanel(self, didChangeLineSpacing: CGFloat(spacing)) 271 | tableView.reloadData() 272 | }) 273 | } 274 | 275 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) 276 | alert.popoverPresentationController?.sourceView = tableView 277 | present(alert, animated: true) 278 | } 279 | 280 | func showParagraphSpacingOptions() { 281 | let alert = UIAlertController(title: "Paragraph Spacing", message: nil, preferredStyle: .actionSheet) 282 | 283 | for spacing in [0, 5, 10, 15, 20, 25, 30] { 284 | alert.addAction(UIAlertAction(title: "\(spacing)pt", style: .default) { [weak self] _ in 285 | guard let self else { return } 286 | paragraphSpacing = CGFloat(spacing) 287 | delegate?.controlPanel(self, didChangeParagraphSpacing: CGFloat(spacing)) 288 | tableView.reloadData() 289 | }) 290 | } 291 | 292 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) 293 | alert.popoverPresentationController?.sourceView = tableView 294 | present(alert, animated: true) 295 | } 296 | 297 | func showTextColorOptions() { 298 | let alert = UIAlertController(title: "Text Color", message: nil, preferredStyle: .actionSheet) 299 | 300 | let colors: [(String, UIColor)] = [ 301 | ("Default", .label), 302 | ("Red", .systemRed), 303 | ("Blue", .systemBlue), 304 | ("Green", .systemGreen), 305 | ("Purple", .systemPurple), 306 | ("Orange", .systemOrange), 307 | ] 308 | 309 | for (name, color) in colors { 310 | alert.addAction(UIAlertAction(title: name, style: .default) { [weak self] _ in 311 | guard let self else { return } 312 | textColor = color 313 | delegate?.controlPanel(self, didChangeTextColor: color) 314 | tableView.reloadData() 315 | }) 316 | } 317 | 318 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) 319 | alert.popoverPresentationController?.sourceView = tableView 320 | present(alert, animated: true) 321 | } 322 | 323 | func showBackgroundColorOptions() { 324 | let alert = UIAlertController(title: "Background Color", message: nil, preferredStyle: .actionSheet) 325 | 326 | let colors: [(String, UIColor?)] = [ 327 | ("None", nil), 328 | ("Light Gray", UIColor.systemGray6), 329 | ("Light Yellow", UIColor.systemYellow.withAlphaComponent(0.15)), 330 | ("Light Green", UIColor.systemGreen.withAlphaComponent(0.15)), 331 | ("Light Blue", UIColor.systemBlue.withAlphaComponent(0.15)), 332 | ("Light Pink", UIColor.systemPink.withAlphaComponent(0.15)), 333 | ("Light Purple", UIColor.systemPurple.withAlphaComponent(0.15)), 334 | ] 335 | 336 | for (name, color) in colors { 337 | alert.addAction(UIAlertAction(title: name, style: .default) { [weak self] _ in 338 | guard let self else { return } 339 | backgroundColor = color 340 | delegate?.controlPanel(self, didChangeBackgroundColor: color) 341 | tableView.reloadData() 342 | }) 343 | } 344 | 345 | alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) 346 | alert.popoverPresentationController?.sourceView = tableView 347 | present(alert, animated: true) 348 | } 349 | } 350 | -------------------------------------------------------------------------------- /LitextSamples/LitextSampleMac/ControlPanelViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created for LitextSampleMac. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import AppKit 7 | 8 | protocol ControlPanelDelegate: AnyObject { 9 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeFontSize fontSize: CGFloat) 10 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeFontWeight fontWeight: NSFont.Weight) 11 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeFontStyle fontStyle: ViewController.FontStyle) 12 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeAlignment alignment: NSTextAlignment) 13 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeLineSpacing lineSpacing: CGFloat) 14 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeParagraphSpacing paragraphSpacing: CGFloat) 15 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeTextColor textColor: NSColor) 16 | func controlPanel(_ controlPanel: ControlPanelViewController, didChangeBackgroundColor backgroundColor: NSColor?) 17 | } 18 | 19 | class ControlPanelViewController: NSViewController { 20 | private let stackView = NSStackView() 21 | private let closeButton = NSButton(title: "关闭", target: nil, action: nil) 22 | 23 | private var fontSizeValueLabel: NSTextField! 24 | private var lineSpacingValueLabel: NSTextField! 25 | private var paragraphSpacingValueLabel: NSTextField! 26 | 27 | var fontSize: CGFloat = 14 28 | var lineSpacing: CGFloat = 4 29 | var paragraphSpacing: CGFloat = 10 30 | var alignment: NSTextAlignment = .left 31 | var textColor: NSColor = .textColor 32 | var fontWeight: NSFont.Weight = .regular 33 | var fontStyle: ViewController.FontStyle = .normal 34 | var backgroundColor: NSColor? = nil 35 | 36 | weak var delegate: ControlPanelDelegate? 37 | 38 | override func loadView() { 39 | view = NSView(frame: NSRect(x: 0, y: 0, width: 500, height: 500)) 40 | preferredContentSize = NSSize(width: 500, height: 500) 41 | } 42 | 43 | override func viewDidLoad() { 44 | super.viewDidLoad() 45 | title = "Text Controls" 46 | 47 | if let window = view.window { 48 | window.minSize = NSSize(width: 200, height: 200) 49 | } 50 | 51 | setupStackView() 52 | setupCloseButton() 53 | setupFontControls() 54 | setupParagraphControls() 55 | setupColorControls() 56 | } 57 | 58 | private func setupStackView() { 59 | stackView.translatesAutoresizingMaskIntoConstraints = false 60 | stackView.orientation = .vertical 61 | stackView.spacing = 20 62 | stackView.alignment = .leading 63 | stackView.distribution = .fill 64 | view.addSubview(stackView) 65 | 66 | NSLayoutConstraint.activate([ 67 | stackView.topAnchor.constraint(equalTo: view.topAnchor, constant: 20), 68 | stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), 69 | stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), 70 | stackView.bottomAnchor.constraint(lessThanOrEqualTo: view.bottomAnchor, constant: -60), // 为关闭按钮留出空间 71 | ]) 72 | } 73 | 74 | private func setupCloseButton() { 75 | closeButton.translatesAutoresizingMaskIntoConstraints = false 76 | closeButton.bezelStyle = .rounded 77 | closeButton.controlSize = .large 78 | closeButton.target = self 79 | closeButton.action = #selector(closePanel) 80 | view.addSubview(closeButton) 81 | 82 | NSLayoutConstraint.activate([ 83 | closeButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), 84 | closeButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20), 85 | closeButton.widthAnchor.constraint(equalToConstant: 100), 86 | closeButton.heightAnchor.constraint(equalToConstant: 30), 87 | ]) 88 | } 89 | 90 | // MARK: - Font Controls 91 | 92 | private func setupFontControls() { 93 | let fontSection = createSectionHeader("Font") 94 | stackView.addArrangedSubview(fontSection) 95 | 96 | // Font Size Control 97 | let sizeRow = NSStackView() 98 | sizeRow.orientation = .horizontal 99 | sizeRow.spacing = 10 100 | 101 | let sizeLabel = NSTextField(labelWithString: "Size:") 102 | let sizeSlider = NSSlider(value: Double(fontSize), minValue: 10, maxValue: 24, target: self, action: #selector(fontSizeChanged(_:))) 103 | fontSizeValueLabel = NSTextField(labelWithString: "\(Int(fontSize))pt") 104 | 105 | sizeRow.addArrangedSubview(sizeLabel) 106 | sizeRow.addArrangedSubview(sizeSlider) 107 | sizeRow.addArrangedSubview(fontSizeValueLabel) 108 | stackView.addArrangedSubview(sizeRow) 109 | 110 | // Font Weight Control 111 | let weightRow = NSStackView() 112 | weightRow.orientation = .horizontal 113 | weightRow.spacing = 10 114 | 115 | let weightLabel = NSTextField(labelWithString: "Weight:") 116 | let weightPopup = NSPopUpButton(frame: .zero, pullsDown: false) 117 | 118 | let weights: [(String, NSFont.Weight)] = [ 119 | ("Regular", .regular), 120 | ("Medium", .medium), 121 | ("Semibold", .semibold), 122 | ("Bold", .bold), 123 | ] 124 | 125 | for (index, (name, weight)) in weights.enumerated() { 126 | weightPopup.addItem(withTitle: name) 127 | if weight == fontWeight { 128 | weightPopup.selectItem(at: index) 129 | } 130 | } 131 | 132 | weightPopup.target = self 133 | weightPopup.action = #selector(fontWeightChanged(_:)) 134 | 135 | weightRow.addArrangedSubview(weightLabel) 136 | weightRow.addArrangedSubview(weightPopup) 137 | stackView.addArrangedSubview(weightRow) 138 | 139 | // Font Style Control 140 | let styleRow = NSStackView() 141 | styleRow.orientation = .horizontal 142 | styleRow.spacing = 10 143 | 144 | let styleLabel = NSTextField(labelWithString: "Style:") 145 | let stylePopup = NSPopUpButton(frame: .zero, pullsDown: false) 146 | 147 | let styles = ["Normal", "Italic", "Bold Italic"] 148 | stylePopup.addItems(withTitles: styles) 149 | stylePopup.selectItem(at: fontStyle.rawValue) 150 | stylePopup.target = self 151 | stylePopup.action = #selector(fontStyleChanged(_:)) 152 | 153 | styleRow.addArrangedSubview(styleLabel) 154 | styleRow.addArrangedSubview(stylePopup) 155 | stackView.addArrangedSubview(styleRow) 156 | } 157 | 158 | // MARK: - Paragraph Controls 159 | 160 | private func setupParagraphControls() { 161 | let paragraphSection = createSectionHeader("Paragraph") 162 | stackView.addArrangedSubview(paragraphSection) 163 | 164 | // Alignment Control 165 | let alignmentRow = NSStackView() 166 | alignmentRow.orientation = .horizontal 167 | alignmentRow.spacing = 10 168 | 169 | let alignmentLabel = NSTextField(labelWithString: "Alignment:") 170 | let alignmentSegment = NSSegmentedControl(labels: ["Left", "Center", "Right", "Justified"], trackingMode: .selectOne, target: self, action: #selector(alignmentChanged(_:))) 171 | 172 | switch alignment { 173 | case .left: alignmentSegment.selectedSegment = 0 174 | case .center: alignmentSegment.selectedSegment = 1 175 | case .right: alignmentSegment.selectedSegment = 2 176 | case .justified: alignmentSegment.selectedSegment = 3 177 | default: alignmentSegment.selectedSegment = 0 178 | } 179 | 180 | alignmentRow.addArrangedSubview(alignmentLabel) 181 | alignmentRow.addArrangedSubview(alignmentSegment) 182 | stackView.addArrangedSubview(alignmentRow) 183 | 184 | // Line Spacing Control 185 | let lineSpacingRow = NSStackView() 186 | lineSpacingRow.orientation = .horizontal 187 | lineSpacingRow.spacing = 10 188 | 189 | let lineSpacingLabel = NSTextField(labelWithString: "Line Spacing:") 190 | let lineSpacingSlider = NSSlider(value: Double(lineSpacing), minValue: 0, maxValue: 20, target: self, action: #selector(lineSpacingChanged(_:))) 191 | lineSpacingValueLabel = NSTextField(labelWithString: "\(Int(lineSpacing))pt") 192 | 193 | lineSpacingRow.addArrangedSubview(lineSpacingLabel) 194 | lineSpacingRow.addArrangedSubview(lineSpacingSlider) 195 | lineSpacingRow.addArrangedSubview(lineSpacingValueLabel) 196 | stackView.addArrangedSubview(lineSpacingRow) 197 | 198 | // Paragraph Spacing Control 199 | let paragraphSpacingRow = NSStackView() 200 | paragraphSpacingRow.orientation = .horizontal 201 | paragraphSpacingRow.spacing = 10 202 | 203 | let paragraphSpacingLabel = NSTextField(labelWithString: "Paragraph Spacing:") 204 | let paragraphSpacingSlider = NSSlider(value: Double(paragraphSpacing), minValue: 0, maxValue: 30, target: self, action: #selector(paragraphSpacingChanged(_:))) 205 | paragraphSpacingValueLabel = NSTextField(labelWithString: "\(Int(paragraphSpacing))pt") 206 | 207 | paragraphSpacingRow.addArrangedSubview(paragraphSpacingLabel) 208 | paragraphSpacingRow.addArrangedSubview(paragraphSpacingSlider) 209 | paragraphSpacingRow.addArrangedSubview(paragraphSpacingValueLabel) 210 | stackView.addArrangedSubview(paragraphSpacingRow) 211 | } 212 | 213 | // MARK: - Color Controls 214 | 215 | private func setupColorControls() { 216 | let colorSection = createSectionHeader("Color") 217 | stackView.addArrangedSubview(colorSection) 218 | 219 | // Text Color Control 220 | let textColorRow = NSStackView() 221 | textColorRow.orientation = .horizontal 222 | textColorRow.spacing = 10 223 | 224 | let textColorLabel = NSTextField(labelWithString: "Text Color:") 225 | let textColorWell = NSColorWell() 226 | textColorWell.color = textColor 227 | textColorWell.target = self 228 | textColorWell.action = #selector(textColorChanged(_:)) 229 | 230 | textColorRow.addArrangedSubview(textColorLabel) 231 | textColorRow.addArrangedSubview(textColorWell) 232 | stackView.addArrangedSubview(textColorRow) 233 | 234 | // Background Color Control 235 | let bgColorRow = NSStackView() 236 | bgColorRow.orientation = .horizontal 237 | bgColorRow.spacing = 10 238 | 239 | let bgColorLabel = NSTextField(labelWithString: "Background:") 240 | let bgColorWell = NSColorWell() 241 | bgColorWell.color = backgroundColor ?? .clear 242 | bgColorWell.target = self 243 | bgColorWell.action = #selector(backgroundColorChanged(_:)) 244 | 245 | let clearBgButton = NSButton(title: "Clear", target: self, action: #selector(clearBackgroundColor(_:))) 246 | clearBgButton.bezelStyle = .rounded 247 | 248 | bgColorRow.addArrangedSubview(bgColorLabel) 249 | bgColorRow.addArrangedSubview(bgColorWell) 250 | bgColorRow.addArrangedSubview(clearBgButton) 251 | stackView.addArrangedSubview(bgColorRow) 252 | } 253 | 254 | private func createSectionHeader(_ title: String) -> NSView { 255 | let headerView = NSView() 256 | headerView.translatesAutoresizingMaskIntoConstraints = false 257 | 258 | let headerLabel = NSTextField(labelWithString: title) 259 | headerLabel.translatesAutoresizingMaskIntoConstraints = false 260 | headerLabel.font = NSFont.boldSystemFont(ofSize: 16) 261 | 262 | let separator = NSBox() 263 | separator.translatesAutoresizingMaskIntoConstraints = false 264 | separator.boxType = .separator 265 | 266 | headerView.addSubview(headerLabel) 267 | headerView.addSubview(separator) 268 | 269 | NSLayoutConstraint.activate([ 270 | headerLabel.topAnchor.constraint(equalTo: headerView.topAnchor), 271 | headerLabel.leadingAnchor.constraint(equalTo: headerView.leadingAnchor), 272 | 273 | separator.topAnchor.constraint(equalTo: headerLabel.bottomAnchor, constant: 4), 274 | separator.leadingAnchor.constraint(equalTo: headerView.leadingAnchor), 275 | separator.trailingAnchor.constraint(equalTo: headerView.trailingAnchor), 276 | separator.bottomAnchor.constraint(equalTo: headerView.bottomAnchor), 277 | 278 | headerView.widthAnchor.constraint(equalToConstant: 460), 279 | headerView.heightAnchor.constraint(equalToConstant: 30), 280 | ]) 281 | 282 | return headerView 283 | } 284 | 285 | // MARK: - Control Actions 286 | 287 | @objc func fontSizeChanged(_ sender: NSSlider) { 288 | fontSize = CGFloat(sender.doubleValue) 289 | fontSizeValueLabel.stringValue = "\(Int(fontSize))pt" 290 | delegate?.controlPanel(self, didChangeFontSize: fontSize) 291 | } 292 | 293 | @objc func fontWeightChanged(_ sender: NSPopUpButton) { 294 | let weights: [NSFont.Weight] = [.regular, .medium, .semibold, .bold] 295 | fontWeight = weights[sender.indexOfSelectedItem] 296 | delegate?.controlPanel(self, didChangeFontWeight: fontWeight) 297 | } 298 | 299 | @objc func fontStyleChanged(_ sender: NSPopUpButton) { 300 | switch sender.indexOfSelectedItem { 301 | case 0: fontStyle = .normal 302 | case 1: fontStyle = .italic 303 | case 2: fontStyle = .boldItalic 304 | default: fontStyle = .normal 305 | } 306 | delegate?.controlPanel(self, didChangeFontStyle: fontStyle) 307 | } 308 | 309 | @objc func alignmentChanged(_ sender: NSSegmentedControl) { 310 | switch sender.selectedSegment { 311 | case 0: alignment = .left 312 | case 1: alignment = .center 313 | case 2: alignment = .right 314 | case 3: alignment = .justified 315 | default: alignment = .left 316 | } 317 | delegate?.controlPanel(self, didChangeAlignment: alignment) 318 | } 319 | 320 | @objc func lineSpacingChanged(_ sender: NSSlider) { 321 | lineSpacing = CGFloat(sender.doubleValue) 322 | lineSpacingValueLabel.stringValue = "\(Int(lineSpacing))pt" 323 | delegate?.controlPanel(self, didChangeLineSpacing: lineSpacing) 324 | } 325 | 326 | @objc func paragraphSpacingChanged(_ sender: NSSlider) { 327 | paragraphSpacing = CGFloat(sender.doubleValue) 328 | paragraphSpacingValueLabel.stringValue = "\(Int(paragraphSpacing))pt" 329 | delegate?.controlPanel(self, didChangeParagraphSpacing: paragraphSpacing) 330 | } 331 | 332 | @objc func textColorChanged(_ sender: NSColorWell) { 333 | textColor = sender.color 334 | delegate?.controlPanel(self, didChangeTextColor: textColor) 335 | } 336 | 337 | @objc func backgroundColorChanged(_ sender: NSColorWell) { 338 | backgroundColor = sender.color 339 | delegate?.controlPanel(self, didChangeBackgroundColor: backgroundColor) 340 | } 341 | 342 | @objc func clearBackgroundColor(_: NSButton) { 343 | backgroundColor = nil 344 | delegate?.controlPanel(self, didChangeBackgroundColor: nil) 345 | } 346 | 347 | // MARK: - 关闭面板 348 | 349 | @objc func closePanel() { 350 | dismiss(nil) 351 | } 352 | } 353 | 354 | // 为FontStyle枚举添加rawValue支持 355 | extension ViewController.FontStyle { 356 | var rawValue: Int { 357 | get { 358 | switch self { 359 | case .normal: 0 360 | case .italic: 1 361 | case .boldItalic: 2 362 | } 363 | } 364 | set {} 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/Interaction/LTXLabel+Interaction@UIKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import CoreText 7 | import Foundation 8 | 9 | private var menuOwnerIdentifier: UUID = .init() 10 | 11 | #if canImport(UIKit) 12 | import UIKit 13 | 14 | public extension LTXLabel { 15 | override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { 16 | guard isSelectable else { 17 | super.pressesBegan(presses, with: event) 18 | return 19 | } 20 | var didHandleEvent = false 21 | for press in presses { 22 | guard let key = press.key else { continue } 23 | if key.charactersIgnoringModifiers == "c", key.modifierFlags.contains(.command) { 24 | let copiedText = copySelectedText() 25 | didHandleEvent = copiedText.length > 0 26 | } 27 | if key.charactersIgnoringModifiers == "a", key.modifierFlags.contains(.command) { 28 | selectAllText() 29 | didHandleEvent = true 30 | } 31 | } 32 | if !didHandleEvent { super.pressesBegan(presses, with: event) } 33 | } 34 | 35 | override var canBecomeFocused: Bool { 36 | isSelectable 37 | } 38 | 39 | override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { 40 | #if !targetEnvironment(macCatalyst) 41 | for handler in [selectionHandleStart, selectionHandleEnd] { 42 | let rect = handler.frame 43 | .insetBy( 44 | dx: -LTXSelectionHandle.knobExtraResponsiveArea, 45 | dy: -LTXSelectionHandle.knobExtraResponsiveArea 46 | ) 47 | if rect.contains(point) { return true } 48 | } 49 | #endif 50 | 51 | if !bounds.contains(point) { return false } 52 | 53 | for view in attachmentViews { 54 | if view.frame.contains(point) { 55 | return super.point(inside: point, with: event) 56 | } 57 | } 58 | 59 | if isSelectable || highlightRegionAtPoint(point) != nil { 60 | return true 61 | } 62 | 63 | return false 64 | } 65 | 66 | override func touchesBegan(_ touches: Set, with event: UIEvent?) { 67 | guard touches.count == 1, 68 | let firstTouch = touches.first 69 | else { 70 | super.touchesBegan(touches, with: event) 71 | return 72 | } 73 | 74 | if isSelectable, !isFirstResponder { 75 | // to received keyboard event from there 76 | _ = becomeFirstResponder() 77 | } 78 | 79 | let location = firstTouch.location(in: self) 80 | setInteractionStateToBegin(initialLocation: location) 81 | 82 | if isLocationAboveAttachmentView(location: location) { 83 | super.touchesBegan(touches, with: event) 84 | return 85 | } 86 | interactionState.isFirstMove = true 87 | 88 | if activateHighlightRegionAtPoint(location) { 89 | return 90 | } 91 | 92 | bumpClickCountIfWithinTimeGap() 93 | if !isSelectable { return } 94 | 95 | if interactionState.clickCount <= 1 { 96 | } else if interactionState.clickCount == 2 { 97 | if let index = textIndexAtPoint(location) { 98 | selectWordAtIndex(index) 99 | // prevent touches did end discard the changes 100 | DispatchQueue.main.asyncAfter(deadline: .now()) { 101 | self.selectWordAtIndex(index) 102 | } 103 | } 104 | } else { 105 | if let index = textIndexAtPoint(location) { 106 | selectLineAtIndex(index) 107 | // prevent touches did end discard the changes 108 | DispatchQueue.main.asyncAfter(deadline: .now()) { 109 | self.selectLineAtIndex(index) 110 | } 111 | } 112 | } 113 | } 114 | 115 | override func touchesMoved(_ touches: Set, with event: UIEvent?) { 116 | guard touches.count == 1, 117 | let firstTouch = touches.first 118 | else { 119 | super.touchesMoved(touches, with: event) 120 | return 121 | } 122 | 123 | let location = firstTouch.location(in: self) 124 | guard isTouchReallyMoved(location) else { return } 125 | defer { self.delegate?.ltxLabelDetectedUserEventMovingAtLocation(self, location: location) } 126 | 127 | deactivateHighlightRegion() 128 | performContinuousStateReset() 129 | 130 | #if canImport(UIKit) && !targetEnvironment(macCatalyst) 131 | // check if is a pointer device 132 | 133 | // on iOS we block the selection change by touches moved 134 | // instead user is required to use those handlers 135 | interactionState.isFirstMove = false 136 | #else 137 | if interactionState.isFirstMove { 138 | interactionState.isFirstMove = false 139 | } 140 | if isSelectable { updateSelectinoRange(withLocation: location) } 141 | #endif 142 | } 143 | 144 | override func touchesEnded(_ touches: Set, with event: UIEvent?) { 145 | isInteractionInProgress = false 146 | guard touches.count == 1, 147 | let firstTouch = touches.first 148 | else { 149 | super.touchesEnded(touches, with: event) 150 | return 151 | } 152 | let location = firstTouch.location(in: self) 153 | defer { deactivateHighlightRegion() } 154 | 155 | if !isTouchReallyMoved(location), 156 | interactionState.clickCount <= 1 157 | { 158 | if isLocationInSelection(location: location) { 159 | #if !targetEnvironment(macCatalyst) 160 | showSelectionMenuController() 161 | #endif 162 | } else { 163 | clearSelection() 164 | } 165 | } 166 | 167 | guard selectionRange == nil, !isTouchReallyMoved(location) else { return } 168 | for region in highlightRegions { 169 | let rects = region.rects.map { 170 | convertRectFromTextLayout($0.cgRectValue, insetForInteraction: true) 171 | } 172 | for rect in rects where rect.contains(location) { 173 | self.delegate?.ltxLabelDidTapOnHighlightContent(self, region: region, location: location) 174 | break 175 | } 176 | } 177 | } 178 | 179 | override func touchesCancelled(_ touches: Set, with event: UIEvent?) { 180 | isInteractionInProgress = false 181 | guard touches.count == 1, 182 | let firstTouch = touches.first 183 | else { 184 | super.touchesCancelled(touches, with: event) 185 | return 186 | } 187 | _ = firstTouch 188 | deactivateHighlightRegion() 189 | } 190 | 191 | // for handling right click on iOS 192 | func installContextMenuInteraction() { 193 | let interaction = UIContextMenuInteraction(delegate: self) 194 | addInteraction(interaction) 195 | } 196 | 197 | func installTextPointerInteraction() { 198 | if #available(iOS 13.4, macCatalyst 13.4, *) { 199 | let pointerInteraction = UIPointerInteraction(delegate: self) 200 | self.addInteraction(pointerInteraction) 201 | } 202 | } 203 | } 204 | 205 | extension LTXLabel: LTXSelectionHandleDelegate { 206 | func selectionHandleDidMove(_ type: LTXSelectionHandle.HandleType, toLocationInSuperView point: CGPoint) { 207 | guard let selectionRange, let textLocation = nearestTextIndexAtPoint(point) else { return } 208 | switch type { 209 | case .start: 210 | let startLocation = min(textLocation, selectionRange.location + selectionRange.length - 1) 211 | let length = selectionRange.location + selectionRange.length - startLocation 212 | self.selectionRange = .init( 213 | location: startLocation, 214 | length: length 215 | ) 216 | case .end: 217 | let startLocation = selectionRange.location 218 | let endingLocation = max(textLocation, startLocation + 1) 219 | self.selectionRange = .init( 220 | location: startLocation, 221 | length: endingLocation - startLocation 222 | ) 223 | } 224 | } 225 | } 226 | 227 | extension LTXLabel: UIContextMenuInteractionDelegate { 228 | public func contextMenuInteraction( 229 | _: UIContextMenuInteraction, 230 | configurationForMenuAtLocation location: CGPoint 231 | ) -> UIContextMenuConfiguration? { 232 | #if targetEnvironment(macCatalyst) 233 | guard selectionRange != nil else { return nil } 234 | var menuItems: [UIMenuElement] = [ 235 | UIAction(title: LocalizedText.copy, image: nil) { _ in 236 | self.copySelectedText() 237 | }, 238 | ] 239 | if selectionRange != selectAllRange() { 240 | menuItems.append( 241 | UIAction(title: LocalizedText.selectAll, image: nil) { _ in 242 | self.selectAllText() 243 | } 244 | ) 245 | } 246 | return .init( 247 | identifier: nil, 248 | previewProvider: nil 249 | ) { _ in 250 | .init(children: menuItems) 251 | } 252 | #else 253 | DispatchQueue.main.async { 254 | guard self.isSelectable else { return } 255 | guard self.isLocationInSelection(location: location) else { return } 256 | self.showSelectionMenuController() 257 | } 258 | return nil 259 | #endif 260 | } 261 | } 262 | 263 | @available(iOS 13.4, macCatalyst 13.4, *) 264 | extension LTXLabel: UIPointerInteractionDelegate { 265 | public func pointerInteraction(_: UIPointerInteraction, styleFor _: UIPointerRegion) -> UIPointerStyle? { 266 | guard isSelectable else { return nil } 267 | guard parentViewController?.presentedViewController == nil else { return nil } 268 | return UIPointerStyle(shape: .verticalBeam(length: 1), constrainedAxes: []) 269 | } 270 | } 271 | 272 | #endif 273 | 274 | #if canImport(UIKit) 275 | 276 | extension LTXLabel { 277 | func showSelectionMenuController() { 278 | guard let range = selectionRange, 279 | range.length > 0, 280 | let textLayout 281 | else { return } 282 | 283 | let rects: [CGRect] = textLayout.rects(for: range).map { 284 | convertRectFromTextLayout($0, insetForInteraction: true) 285 | } 286 | guard !rects.isEmpty, var unionRect = rects.first else { return } 287 | 288 | for rect in rects.dropFirst() { 289 | unionRect = unionRect.union(rect) 290 | } 291 | 292 | let menuController = UIMenuController.shared 293 | 294 | var menuItems: [UIMenuItem] = [] 295 | menuItems.append(UIMenuItem( 296 | title: LocalizedText.copy, 297 | action: #selector(copyMenuItemTapped) 298 | )) 299 | if selectionRange != selectAllRange() { 300 | menuItems.append(UIMenuItem( 301 | title: LocalizedText.selectAll, 302 | action: #selector(selectAllTapped) 303 | )) 304 | } 305 | menuController.menuItems = menuItems 306 | 307 | menuOwnerIdentifier = id 308 | menuController.showMenu( 309 | from: self, 310 | rect: unionRect.insetBy(dx: -8, dy: -8) 311 | ) 312 | } 313 | 314 | func hideSelectionMenuController() { 315 | guard menuOwnerIdentifier == id else { return } 316 | UIMenuController.shared.hideMenu() 317 | } 318 | 319 | @objc private func copyMenuItemTapped() { 320 | let copiedText = copySelectedText() 321 | if copiedText.length <= 0 { 322 | _ = copyFromSubviewsRecursively() 323 | } 324 | clearSelection() 325 | } 326 | 327 | @objc private func selectAllTapped() { 328 | selectAllText() 329 | DispatchQueue.main.async { 330 | self.showSelectionMenuController() 331 | } 332 | } 333 | 334 | @objc private func copyKeyCommand() { 335 | let copiedText = copySelectedText() 336 | if copiedText.length <= 0 { 337 | _ = copyFromSubviewsRecursively() 338 | } 339 | } 340 | 341 | override public var canBecomeFirstResponder: Bool { 342 | isSelectable 343 | } 344 | 345 | override public func canPerformAction( 346 | _ action: Selector, 347 | withSender sender: Any? 348 | ) -> Bool { 349 | if action == #selector(copyMenuItemTapped) { 350 | return selectionRange != nil 351 | && selectionRange!.length > 0 352 | } 353 | return super.canPerformAction( 354 | action, 355 | withSender: sender 356 | ) 357 | } 358 | 359 | private func copyFromSubviewsRecursively() -> Bool { 360 | copyFromSubviewsRecursively(in: self) 361 | } 362 | 363 | private func copyFromSubviewsRecursively(in view: UIView) -> Bool { 364 | for subview in view.subviews { 365 | if let ltxLabel = subview as? LTXLabel { 366 | let copiedText = ltxLabel.copySelectedText() 367 | if copiedText.length > 0 { 368 | return true 369 | } 370 | } else { 371 | if copyFromSubviewsRecursively(in: subview) { 372 | return true 373 | } 374 | } 375 | } 376 | return false 377 | } 378 | } 379 | 380 | #endif 381 | 382 | #if canImport(UIKit) 383 | 384 | extension UIView { 385 | var parentViewController: UIViewController? { 386 | weak var parentResponder: UIResponder? = self 387 | while parentResponder != nil { 388 | parentResponder = parentResponder!.next 389 | if let viewController = parentResponder as? UIViewController { 390 | return viewController 391 | } 392 | } 393 | return nil 394 | } 395 | } 396 | #endif 397 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import Litext 7 | import SafariServices 8 | import UIKit 9 | 10 | class ViewController: UIViewController { 11 | let scrollView = UIScrollView() 12 | let contentView = UIView() 13 | let label = LTXLabel() 14 | let controlButton = UIButton(type: .system) 15 | 16 | var fontSize: CGFloat = 16 17 | var lineSpacing: CGFloat = 6 18 | var paragraphSpacing: CGFloat = 10 19 | var alignment: NSTextAlignment = .left 20 | var textColor: UIColor = .label 21 | var fontWeight: UIFont.Weight = .regular 22 | var fontStyle: FontStyle = .normal 23 | var backgroundColor: UIColor = .clear 24 | 25 | enum FontStyle { 26 | case normal 27 | case italic 28 | case boldItalic 29 | } 30 | 31 | override func viewDidLoad() { 32 | super.viewDidLoad() 33 | 34 | title = "Litext Demo" 35 | view.backgroundColor = .systemBackground 36 | 37 | setupLayout() 38 | updateAttributedText() 39 | } 40 | 41 | func setupLayout() { 42 | // Setup scroll view 43 | scrollView.translatesAutoresizingMaskIntoConstraints = false 44 | scrollView.alwaysBounceVertical = true 45 | scrollView.showsVerticalScrollIndicator = true 46 | view.addSubview(scrollView) 47 | 48 | // Setup content view 49 | contentView.translatesAutoresizingMaskIntoConstraints = false 50 | scrollView.addSubview(contentView) 51 | 52 | // Setup label 53 | label.translatesAutoresizingMaskIntoConstraints = false 54 | label.backgroundColor = backgroundColor 55 | contentView.addSubview(label) 56 | 57 | // Setup control button 58 | controlButton.translatesAutoresizingMaskIntoConstraints = false 59 | controlButton.setTitle("Text Controls", for: .normal) 60 | controlButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium) 61 | controlButton.backgroundColor = .systemBlue 62 | controlButton.setTitleColor(.white, for: .normal) 63 | controlButton.layer.cornerRadius = 12 64 | controlButton.addTarget(self, action: #selector(showControlPanel), for: .touchUpInside) 65 | view.addSubview(controlButton) 66 | 67 | // Layout constraints 68 | NSLayoutConstraint.activate([ 69 | scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), 70 | scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), 71 | scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), 72 | scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -60), 73 | 74 | contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), 75 | contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), 76 | contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), 77 | contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), 78 | contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), 79 | 80 | label.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), 81 | label.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), 82 | label.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), 83 | label.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20), 84 | 85 | controlButton.centerXAnchor.constraint(equalTo: view.centerXAnchor), 86 | controlButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10), 87 | controlButton.widthAnchor.constraint(equalToConstant: 150), 88 | controlButton.heightAnchor.constraint(equalToConstant: 44), 89 | ]) 90 | } 91 | 92 | override func viewDidLayoutSubviews() { 93 | super.viewDidLayoutSubviews() 94 | 95 | let margins = view.layoutMargins 96 | label.preferredMaxLayoutWidth = view.bounds.width - margins.left - margins.right - 40 97 | 98 | let contentHeight = max( 99 | label.intrinsicContentSize.height + 200, 100 | scrollView.bounds.height 101 | ) 102 | let cons = contentView.heightAnchor.constraint(equalToConstant: contentHeight) 103 | cons.priority = .required 104 | cons.isActive = true 105 | } 106 | 107 | @objc func showControlPanel() { 108 | let controlPanelVC = ControlPanelViewController() 109 | controlPanelVC.isModalInPresentation = false 110 | 111 | controlPanelVC.delegate = self 112 | 113 | controlPanelVC.fontSize = fontSize 114 | controlPanelVC.lineSpacing = lineSpacing 115 | controlPanelVC.paragraphSpacing = paragraphSpacing 116 | controlPanelVC.alignment = alignment 117 | controlPanelVC.textColor = textColor 118 | controlPanelVC.fontWeight = fontWeight 119 | controlPanelVC.fontStyle = fontStyle 120 | controlPanelVC.backgroundColor = backgroundColor 121 | 122 | if let sheet = controlPanelVC.sheetPresentationController { 123 | sheet.detents = [.medium()] 124 | sheet.prefersGrabberVisible = true 125 | sheet.preferredCornerRadius = 20 126 | } 127 | 128 | present(controlPanelVC, animated: true) 129 | } 130 | 131 | func updateAttributedText() { 132 | let attributedString = NSMutableAttributedString() 133 | 134 | attributedString.append( 135 | NSAttributedString( 136 | string: "Hello, Litext!\n\n", 137 | attributes: [ 138 | .font: UIFont.systemFont(ofSize: fontSize + 2, weight: .bold), 139 | .foregroundColor: textColor, 140 | ] 141 | ) 142 | ) 143 | 144 | attributedString.append( 145 | NSAttributedString( 146 | string: "This is a rich text label supporting ", 147 | attributes: [ 148 | .font: createFont(), 149 | .foregroundColor: textColor, 150 | ] 151 | ) 152 | ) 153 | 154 | attributedString.append( 155 | NSAttributedString( 156 | string: "clickable links", 157 | attributes: [ 158 | .font: createFont(), 159 | .link: URL(string: "https://example.com")!, 160 | .foregroundColor: UIColor.systemBlue, 161 | ] 162 | ) 163 | ) 164 | 165 | attributedString.append( 166 | NSAttributedString( 167 | string: ", ", 168 | attributes: [ 169 | .font: createFont(), 170 | .foregroundColor: textColor, 171 | ] 172 | ) 173 | ) 174 | 175 | attributedString.append( 176 | NSAttributedString( 177 | string: "underlined text", 178 | attributes: [ 179 | .font: createFont(), 180 | .foregroundColor: textColor, 181 | .underlineStyle: NSUnderlineStyle.single.rawValue, 182 | .underlineColor: UIColor.systemBlue, 183 | ] 184 | ) 185 | ) 186 | 187 | attributedString.append( 188 | NSAttributedString( 189 | string: ", ", 190 | attributes: [ 191 | .font: createFont(), 192 | .foregroundColor: textColor, 193 | ] 194 | ) 195 | ) 196 | 197 | attributedString.append( 198 | NSAttributedString( 199 | string: "strikethrough", 200 | attributes: [ 201 | .font: createFont(), 202 | .foregroundColor: textColor, 203 | .strikethroughStyle: NSUnderlineStyle.single.rawValue, 204 | .strikethroughColor: UIColor.systemRed, 205 | ] 206 | ) 207 | ) 208 | 209 | attributedString.append( 210 | NSAttributedString( 211 | string: " and embedded views:\n\n", 212 | attributes: [ 213 | .font: createFont(), 214 | .foregroundColor: textColor, 215 | ] 216 | ) 217 | ) 218 | 219 | let attachment = LTXAttachment() 220 | let switchView = UISwitch() 221 | attachment.view = switchView 222 | attachment.size = switchView.intrinsicContentSize 223 | 224 | attributedString.append( 225 | NSAttributedString( 226 | string: LTXReplacementText, 227 | attributes: [ 228 | LTXAttachmentAttributeName: attachment, 229 | kCTRunDelegateAttributeName as NSAttributedString.Key: attachment.runDelegate, 230 | ] 231 | ) 232 | ) 233 | 234 | attributedString.append( 235 | NSAttributedString( 236 | string: " Toggle Switch", 237 | attributes: [ 238 | .font: createFont(), 239 | .foregroundColor: textColor, 240 | ] 241 | ) 242 | ) 243 | 244 | let buttonAttachment = LTXAttachment() 245 | let button = UIButton(type: .system) 246 | button.setTitle("Click Me", for: .normal) 247 | button.sizeToFit() 248 | buttonAttachment.view = button 249 | buttonAttachment.size = button.intrinsicContentSize 250 | 251 | attributedString.append( 252 | NSAttributedString( 253 | string: "\n\n", 254 | attributes: [ 255 | .font: createFont(), 256 | .foregroundColor: textColor, 257 | ] 258 | ) 259 | ) 260 | 261 | attributedString.append( 262 | NSAttributedString( 263 | string: LTXReplacementText, 264 | attributes: [ 265 | LTXAttachmentAttributeName: buttonAttachment, 266 | kCTRunDelegateAttributeName as NSAttributedString.Key: buttonAttachment.runDelegate, 267 | ] 268 | ) 269 | ) 270 | 271 | attributedString.append( 272 | NSAttributedString( 273 | string: "\n\n组合样式:", 274 | attributes: [ 275 | .font: createFont(), 276 | .foregroundColor: textColor, 277 | ] 278 | ) 279 | ) 280 | 281 | attributedString.append( 282 | NSAttributedString( 283 | string: "粗体带下划线", 284 | attributes: [ 285 | .font: UIFont.systemFont(ofSize: fontSize, weight: .bold), 286 | .foregroundColor: UIColor.systemPurple, 287 | .underlineStyle: NSUnderlineStyle.single.rawValue, 288 | ] 289 | ) 290 | ) 291 | 292 | attributedString.append( 293 | NSAttributedString( 294 | string: " 和 ", 295 | attributes: [ 296 | .font: createFont(), 297 | .foregroundColor: textColor, 298 | ] 299 | ) 300 | ) 301 | 302 | attributedString.append( 303 | NSAttributedString( 304 | string: "高亮背景文本", 305 | attributes: [ 306 | .font: createFont(), 307 | .foregroundColor: UIColor.black, 308 | .backgroundColor: UIColor.systemYellow, 309 | ] 310 | ) 311 | ) 312 | 313 | attributedString.append( 314 | NSAttributedString( 315 | string: "\n\n中文测试,那只敏捷的棕毛狐狸🦊跳上了那只懒狗🐶。", 316 | attributes: [ 317 | .font: createFont(), 318 | .foregroundColor: textColor, 319 | ] 320 | ) 321 | ) 322 | 323 | attributedString.append( 324 | NSAttributedString( 325 | string: "\n\n这是一段被删除的文字,", 326 | attributes: [ 327 | .font: createFont(), 328 | .foregroundColor: UIColor.systemGray, 329 | .strikethroughStyle: NSUnderlineStyle.single.rawValue, 330 | ] 331 | ) 332 | ) 333 | 334 | attributedString.append( 335 | NSAttributedString( 336 | string: "这是新文字。", 337 | attributes: [ 338 | .font: createFont(), 339 | .foregroundColor: UIColor.systemGreen, 340 | ] 341 | ) 342 | ) 343 | 344 | let paragraphStyle = NSMutableParagraphStyle() 345 | paragraphStyle.lineSpacing = lineSpacing 346 | paragraphStyle.paragraphSpacing = paragraphSpacing 347 | paragraphStyle.alignment = alignment 348 | 349 | attributedString.addAttributes( 350 | [.paragraphStyle: paragraphStyle], 351 | range: NSRange(location: 0, length: attributedString.length) 352 | ) 353 | 354 | label.backgroundColor = backgroundColor 355 | label.isSelectable = true 356 | 357 | label.attributedText = attributedString 358 | label.delegate = self 359 | } 360 | 361 | private func createFont() -> UIFont { 362 | var font: UIFont 363 | 364 | switch fontStyle { 365 | case .normal: 366 | font = UIFont.systemFont(ofSize: fontSize, weight: fontWeight) 367 | case .italic: 368 | let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) 369 | .withSymbolicTraits(.traitItalic)! 370 | font = UIFont(descriptor: descriptor, size: fontSize) 371 | case .boldItalic: 372 | let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body) 373 | .withSymbolicTraits([.traitItalic, .traitBold])! 374 | font = UIFont(descriptor: descriptor, size: fontSize) 375 | } 376 | 377 | return font 378 | } 379 | 380 | func handleLinkTap(_ url: URL) { 381 | let safariVC = SFSafariViewController(url: url) 382 | present(safariVC, animated: true) 383 | } 384 | 385 | func updateTextAttributesForStyleChange() { 386 | updateAttributedText() 387 | } 388 | } 389 | 390 | extension ViewController: LTXLabelDelegate { 391 | func ltxLabelDidTapOnHighlightContent(_: Litext.LTXLabel, region: Litext.LTXHighlightRegion?, location _: CGPoint) { 392 | if let url = region?.attributes[.link] as? URL { 393 | handleLinkTap(url) 394 | } 395 | } 396 | 397 | func ltxLabelSelectionDidChange(_: Litext.LTXLabel, selection: NSRange?) { 398 | print(String(describing: selection)) 399 | } 400 | 401 | func ltxLabelDetectedUserEventMovingAtLocation(_ ltxLabel: LTXLabel, location: CGPoint) { 402 | print(#function, ltxLabel, location) 403 | } 404 | } 405 | 406 | // MARK: - ControlPanelDelegate 407 | 408 | extension ViewController: ControlPanelDelegate { 409 | func controlPanel(_: ControlPanelViewController, didChangeFontSize fontSize: CGFloat) { 410 | self.fontSize = fontSize 411 | updateAttributedText() 412 | } 413 | 414 | func controlPanel(_: ControlPanelViewController, didChangeFontWeight fontWeight: UIFont.Weight) { 415 | self.fontWeight = fontWeight 416 | updateAttributedText() 417 | } 418 | 419 | func controlPanel(_: ControlPanelViewController, didChangeFontStyle fontStyle: FontStyle) { 420 | self.fontStyle = fontStyle 421 | updateAttributedText() 422 | } 423 | 424 | func controlPanel(_: ControlPanelViewController, didChangeAlignment alignment: NSTextAlignment) { 425 | self.alignment = alignment 426 | updateAttributedText() 427 | } 428 | 429 | func controlPanel(_: ControlPanelViewController, didChangeLineSpacing lineSpacing: CGFloat) { 430 | self.lineSpacing = lineSpacing 431 | updateAttributedText() 432 | } 433 | 434 | func controlPanel(_: ControlPanelViewController, didChangeParagraphSpacing paragraphSpacing: CGFloat) { 435 | self.paragraphSpacing = paragraphSpacing 436 | updateAttributedText() 437 | } 438 | 439 | func controlPanel(_: ControlPanelViewController, didChangeTextColor textColor: UIColor) { 440 | self.textColor = textColor 441 | updateAttributedText() 442 | } 443 | 444 | func controlPanel(_: ControlPanelViewController, didChangeBackgroundColor backgroundColor: UIColor?) { 445 | self.backgroundColor = backgroundColor ?? .clear 446 | updateAttributedText() 447 | } 448 | } 449 | -------------------------------------------------------------------------------- /Sources/Litext/LTXLabel/TextLayout/LTXTextLayout.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Created by Lakr233 & Helixform on 2025/2/18. 3 | // Copyright (c) 2025 Litext Team. All rights reserved. 4 | // 5 | 6 | import CoreGraphics 7 | import CoreText 8 | import Foundation 9 | import QuartzCore 10 | 11 | private let kTruncationToken = "\u{2026}" 12 | 13 | private func _hasHighlightAttributes(_ attributes: [NSAttributedString.Key: Any]) -> Bool { 14 | if attributes[.link] != nil { 15 | return true 16 | } 17 | if attributes[LTXAttachmentAttributeName] != nil { 18 | return true 19 | } 20 | return false 21 | } 22 | 23 | private func _createTruncatedLine( 24 | lastLine: CTLine, 25 | attrString: NSAttributedString, 26 | width: CGFloat 27 | ) -> CTLine? { 28 | let truncationTokenAttributes = extractTruncationAttributes(from: lastLine) 29 | 30 | let truncationTokenString = NSAttributedString( 31 | string: kTruncationToken, 32 | attributes: truncationTokenAttributes 33 | ) 34 | let truncationLine = CTLineCreateWithAttributedString( 35 | truncationTokenString 36 | ) 37 | 38 | let lastLineStringRange = CTLineGetStringRange(lastLine) 39 | let nsRange = NSRange( 40 | location: lastLineStringRange.location, 41 | length: lastLineStringRange.length 42 | ) 43 | 44 | let lastLineString = NSMutableAttributedString( 45 | attributedString: attrString.attributedSubstring( 46 | from: nsRange 47 | ) 48 | ) 49 | lastLineString.append(truncationTokenString) 50 | let newLastLine = CTLineCreateWithAttributedString( 51 | lastLineString 52 | ) 53 | 54 | let truncatedLine = CTLineCreateTruncatedLine( 55 | newLastLine, 56 | width, 57 | .end, 58 | truncationLine 59 | ) 60 | 61 | return truncatedLine 62 | } 63 | 64 | private func extractTruncationAttributes( 65 | from line: CTLine 66 | ) -> [NSAttributedString.Key: Any] { 67 | var attributes: [NSAttributedString.Key: Any] = [:] 68 | 69 | let lastLineGlyphRuns = CTLineGetGlyphRuns(line) as NSArray 70 | if let lastGlyphRun = lastLineGlyphRuns.lastObject as! CTRun? { 71 | let lastRunAttributes = CTRunGetAttributes(lastGlyphRun) as! [NSAttributedString.Key: Any] 72 | 73 | if let font = lastRunAttributes[.font] { 74 | attributes[.font] = font 75 | } 76 | if let foregroundColor = lastRunAttributes[.foregroundColor] { 77 | attributes[.foregroundColor] = foregroundColor 78 | } 79 | if let paragraphStyle = lastRunAttributes[.paragraphStyle] { 80 | attributes[.paragraphStyle] = paragraphStyle 81 | } 82 | } 83 | 84 | return attributes 85 | } 86 | 87 | public class LTXTextLayout: NSObject { 88 | public private(set) var attributedString: NSAttributedString 89 | public var highlightRegions: [LTXHighlightRegion] { 90 | Array(_highlightRegions.values) 91 | } 92 | 93 | public var containerSize: CGSize { 94 | didSet { 95 | generateLayout() 96 | } 97 | } 98 | 99 | var ctFrame: CTFrame? 100 | 101 | private var framesetter: CTFramesetter 102 | private var lines: [CTLine]? 103 | private var _highlightRegions: [Int: LTXHighlightRegion] 104 | private var lineDrawingActions: Set = [] 105 | 106 | public class func textLayout( 107 | withAttributedString attributedString: NSAttributedString 108 | ) -> LTXTextLayout { 109 | LTXTextLayout(attributedString: attributedString) 110 | } 111 | 112 | public init(attributedString: NSAttributedString) { 113 | self.attributedString = attributedString 114 | containerSize = .zero 115 | framesetter = CTFramesetterCreateWithAttributedString( 116 | attributedString 117 | ) 118 | _highlightRegions = [:] 119 | super.init() 120 | } 121 | 122 | deinit {} 123 | 124 | public func invalidateLayout() { 125 | generateLayout() 126 | } 127 | 128 | public func suggestContainerSize(withSize size: CGSize) -> CGSize { 129 | CTFramesetterSuggestFrameSizeWithConstraints( 130 | framesetter, 131 | CFRange(location: 0, length: 0), 132 | nil, 133 | size, 134 | nil 135 | ) 136 | } 137 | 138 | public func draw(in context: CGContext) { 139 | lineDrawingActions.removeAll() 140 | 141 | context.saveGState() 142 | 143 | context.setAllowsAntialiasing(true) 144 | context.setShouldSmoothFonts(true) 145 | 146 | context.translateBy(x: 0, y: containerSize.height) 147 | context.scaleBy(x: 1, y: -1) 148 | 149 | if let ctFrame { CTFrameDraw(ctFrame, context) } 150 | 151 | processLineDrawingActions(in: context) 152 | 153 | context.restoreGState() 154 | } 155 | 156 | private func processLineDrawingActions(in context: CGContext) { 157 | enumerateLines { line, _, lineOrigin in 158 | let glyphRuns = CTLineGetGlyphRuns(line) as NSArray 159 | 160 | for i in 0 ..< glyphRuns.count { 161 | guard let glyphRun = glyphRuns[i] as! CTRun? 162 | else { continue } 163 | 164 | let attributes = CTRunGetAttributes(glyphRun) as! [NSAttributedString.Key: Any] 165 | if let action = attributes[LTXLineDrawingCallbackName] as? LTXLineDrawingAction { 166 | if self.lineDrawingActions.contains(action) { 167 | continue 168 | } 169 | context.saveGState() 170 | action.action(context, line, lineOrigin) 171 | context.restoreGState() 172 | if action.performOncePerAttribute { 173 | self.lineDrawingActions.insert(action) 174 | } 175 | } 176 | } 177 | } 178 | } 179 | 180 | public func updateHighlightRegions(with context: CGContext) { 181 | _highlightRegions.removeAll() 182 | extractHighlightRegions(with: context) 183 | } 184 | 185 | public func rects(for range: NSRange) -> [CGRect] { 186 | var rects = [CGRect]() 187 | enumerateTextRects(in: range) { rect in 188 | rects.append(rect) 189 | } 190 | return rects 191 | } 192 | 193 | public func enumerateTextRects(in range: NSRange, using block: (CGRect) -> Void) { 194 | guard let ctFrame else { return } 195 | 196 | let lines = CTFrameGetLines(ctFrame) as NSArray 197 | let lineCount = lines.count 198 | var origins = [CGPoint](repeating: .zero, count: lineCount) 199 | CTFrameGetLineOrigins(ctFrame, CFRange(location: 0, length: 0), &origins) 200 | 201 | for i in 0 ..< lineCount { 202 | let line = lines[i] as! CTLine 203 | let lineRange = CTLineGetStringRange(line) 204 | 205 | let lineStart = lineRange.location 206 | let lineEnd = lineStart + lineRange.length 207 | let selStart = range.location 208 | let selEnd = selStart + range.length 209 | 210 | if selEnd < lineStart || selStart > lineEnd { 211 | continue 212 | } 213 | 214 | let overlapStart = max(lineStart, selStart) 215 | let overlapEnd = min(lineEnd, selEnd) 216 | 217 | if overlapStart >= overlapEnd { 218 | continue 219 | } 220 | 221 | calculateAndAddTextRect( 222 | for: line, 223 | origin: origins[i], 224 | overlapStart: overlapStart, 225 | overlapEnd: overlapEnd, 226 | lineStart: lineStart, 227 | lineEnd: lineEnd, 228 | using: block 229 | ) 230 | } 231 | } 232 | 233 | private func calculateAndAddTextRect( 234 | for line: CTLine, 235 | origin: CGPoint, 236 | overlapStart: CFIndex, 237 | overlapEnd: CFIndex, 238 | lineStart: CFIndex, 239 | lineEnd: CFIndex, 240 | using block: (CGRect) -> Void 241 | ) { 242 | var startOffset: CGFloat = 0 243 | var endOffset: CGFloat = 0 244 | 245 | if overlapStart > lineStart { 246 | startOffset = CTLineGetOffsetForStringIndex( 247 | line, 248 | overlapStart, 249 | nil 250 | ) 251 | } 252 | 253 | if overlapEnd < lineEnd { 254 | endOffset = CTLineGetOffsetForStringIndex( 255 | line, 256 | overlapEnd, 257 | nil 258 | ) 259 | } else { 260 | endOffset = CTLineGetTypographicBounds( 261 | line, 262 | nil, 263 | nil, 264 | nil 265 | ) 266 | } 267 | 268 | var ascent: CGFloat = 0 269 | var descent: CGFloat = 0 270 | var leading: CGFloat = 0 271 | CTLineGetTypographicBounds( 272 | line, 273 | &ascent, 274 | &descent, 275 | &leading 276 | ) 277 | 278 | let rect = CGRect( 279 | x: origin.x + startOffset, 280 | y: origin.y - descent, 281 | width: endOffset - startOffset, 282 | height: ascent + descent + leading 283 | ) 284 | 285 | block(rect) 286 | } 287 | 288 | // MARK: - Private Methods 289 | 290 | private func generateLayout() { 291 | lines = nil 292 | 293 | let containerBounds = CGRect( 294 | origin: .zero, 295 | size: containerSize 296 | ) 297 | let containerPath = CGPath( 298 | rect: containerBounds, 299 | transform: nil 300 | ) 301 | ctFrame = CTFramesetterCreateFrame( 302 | framesetter, 303 | CFRange(location: 0, length: 0), 304 | containerPath, 305 | nil 306 | ) 307 | 308 | if let ctFrame { 309 | lines = CTFrameGetLines(ctFrame) as? [CTLine] 310 | } 311 | 312 | processTruncation(in: containerBounds) 313 | } 314 | 315 | private func processTruncation(in containerBounds: CGRect) { 316 | if let lines, let ctFrame { 317 | let visibleRange = CTFrameGetVisibleStringRange( 318 | ctFrame 319 | ) 320 | if visibleRange.length == attributedString.length || lines.isEmpty { 321 | return 322 | } 323 | 324 | if let lastLine = lines.last, 325 | let truncatedLine = _createTruncatedLine( 326 | lastLine: lastLine, 327 | attrString: attributedString, 328 | width: containerBounds.width 329 | ) 330 | { 331 | var newLines = lines 332 | newLines[newLines.count - 1] = truncatedLine 333 | self.lines = newLines 334 | } 335 | } 336 | } 337 | 338 | private func extractHighlightRegions(with context: CGContext) { 339 | enumerateLines { line, _, lineOrigin in 340 | let glyphRuns = CTLineGetGlyphRuns(line) as NSArray 341 | 342 | for i in 0 ..< glyphRuns.count { 343 | guard let glyphRun = glyphRuns[i] as! CTRun? else { continue } 344 | 345 | let attributes = CTRunGetAttributes( 346 | glyphRun 347 | ) as! [NSAttributedString.Key: Any] 348 | if !_hasHighlightAttributes(attributes) { 349 | continue 350 | } 351 | 352 | processHighlightRegionForRun( 353 | glyphRun, 354 | attributes: attributes, 355 | lineOrigin: lineOrigin, 356 | with: context 357 | ) 358 | } 359 | } 360 | } 361 | 362 | private func processHighlightRegionForRun( 363 | _ glyphRun: CTRun, 364 | attributes: [NSAttributedString.Key: Any], 365 | lineOrigin: CGPoint, 366 | with context: CGContext 367 | ) { 368 | let cfStringRange = CTRunGetStringRange(glyphRun) 369 | let stringRange = NSRange( 370 | location: cfStringRange.location, 371 | length: cfStringRange.length 372 | ) 373 | 374 | var effectiveRange = NSRange() 375 | _ = attributedString.attributes( 376 | at: stringRange.location, 377 | effectiveRange: &effectiveRange 378 | ) 379 | 380 | let highlightRegion: LTXHighlightRegion 381 | if let existingRegion = _highlightRegions[ 382 | effectiveRange.location 383 | ] { 384 | highlightRegion = existingRegion 385 | } else { 386 | highlightRegion = LTXHighlightRegion( 387 | attributes: attributes, 388 | stringRange: stringRange 389 | ) 390 | _highlightRegions[effectiveRange.location] = highlightRegion 391 | } 392 | 393 | var runBounds = CTRunGetImageBounds( 394 | glyphRun, 395 | context, 396 | CFRange(location: 0, length: 0) 397 | ) 398 | 399 | if let attachment = attributes[ 400 | LTXAttachmentAttributeName 401 | ] as? LTXAttachment { 402 | runBounds.size = attachment.size 403 | runBounds.origin.y -= attachment.size.height * 0.1 404 | } 405 | 406 | runBounds.origin.x += lineOrigin.x 407 | runBounds.origin.y += lineOrigin.y 408 | highlightRegion.addRect(runBounds) 409 | } 410 | 411 | private func enumerateLines( 412 | using block: (CTLine, Int, CGPoint) -> Void 413 | ) { 414 | guard let lines, let ctFrame else { return } 415 | 416 | let lineCount = lines.count 417 | var lineOrigins = [CGPoint](repeating: .zero, count: lineCount) 418 | CTFrameGetLineOrigins( 419 | ctFrame, 420 | CFRange(location: 0, length: 0), 421 | &lineOrigins 422 | ) 423 | 424 | for i in 0 ..< lineCount { 425 | let line = lines[i] 426 | let origin = lineOrigins[i] 427 | block(line, i, origin) 428 | } 429 | } 430 | 431 | // MARK: - Text Index Helpers 432 | 433 | public func textIndex(at point: CGPoint) -> Int? { 434 | guard let ctFrame else { return nil } 435 | 436 | if let lineInfo = findLineContainingPoint(point, ctFrame: ctFrame) { 437 | return findCharacterIndexInLine(point, lineInfo: lineInfo) 438 | } 439 | 440 | let lines = CTFrameGetLines(ctFrame) as [AnyObject] 441 | guard !lines.isEmpty else { return nil } 442 | var lineOrigins = [CGPoint](repeating: .zero, count: lines.count) 443 | CTFrameGetLineOrigins(ctFrame, CFRange(location: 0, length: 0), &lineOrigins) 444 | 445 | guard point.y < lineOrigins[lines.count - 1].y else { return nil } 446 | let lastLine = lines[lines.count - 1] as! CTLine 447 | let range = CTLineGetStringRange(lastLine) 448 | return range.location + range.length 449 | } 450 | 451 | public func nearestTextIndex(at point: CGPoint) -> Int? { 452 | guard let ctFrame else { return nil } 453 | 454 | if let lineInfo = findLineContainingPoint(point, ctFrame: ctFrame) { 455 | return findCharacterIndexInLine(point, lineInfo: lineInfo) 456 | } 457 | 458 | let lines = CTFrameGetLines(ctFrame) as [AnyObject] 459 | guard !lines.isEmpty else { return nil } 460 | 461 | var lineOrigins = [CGPoint](repeating: .zero, count: lines.count) 462 | CTFrameGetLineOrigins(ctFrame, CFRange(location: 0, length: 0), &lineOrigins) 463 | 464 | // 如果点在文本上方 465 | if point.y > lineOrigins[0].y { 466 | let firstLine = lines[0] as! CTLine 467 | if point.x < lineOrigins[0].x { 468 | return CTLineGetStringRange(firstLine).location 469 | } else { 470 | let range = CTLineGetStringRange(firstLine) 471 | let lineWidth = CTLineGetTypographicBounds(firstLine, nil, nil, nil) 472 | if point.x > lineOrigins[0].x + lineWidth { 473 | return range.location + range.length 474 | } else { 475 | return findCharacterIndexInLine(point, lineInfo: (firstLine, lineOrigins[0], 0)) 476 | } 477 | } 478 | } 479 | 480 | // 如果点在文本下方 481 | if point.y < lineOrigins[lines.count - 1].y { 482 | let lastLine = lines[lines.count - 1] as! CTLine 483 | if point.x < lineOrigins[lines.count - 1].x { 484 | return CTLineGetStringRange(lastLine).location 485 | } else { 486 | let range = CTLineGetStringRange(lastLine) 487 | let lineWidth = CTLineGetTypographicBounds(lastLine, nil, nil, nil) 488 | if point.x > lineOrigins[lines.count - 1].x + lineWidth { 489 | return range.location + range.length 490 | } else { 491 | return findCharacterIndexInLine(point, lineInfo: (lastLine, lineOrigins[lines.count - 1], lines.count - 1)) 492 | } 493 | } 494 | } 495 | 496 | // 如果点在两行之间,找到最近的行 497 | var closestLineIndex = 0 498 | var minDistance = CGFloat.greatestFiniteMagnitude 499 | 500 | for i in 0 ..< lines.count { 501 | let line = lines[i] as! CTLine 502 | let origin = lineOrigins[i] 503 | var ascent: CGFloat = 0 504 | var descent: CGFloat = 0 505 | var leading: CGFloat = 0 506 | 507 | CTLineGetTypographicBounds(line, &ascent, &descent, &leading) 508 | 509 | let lineMiddleY = origin.y - descent + (ascent + descent) / 2 510 | let distance = abs(point.y - lineMiddleY) 511 | 512 | if distance < minDistance { 513 | minDistance = distance 514 | closestLineIndex = i 515 | } 516 | } 517 | 518 | let closestLine = lines[closestLineIndex] as! CTLine 519 | let closestOrigin = lineOrigins[closestLineIndex] 520 | 521 | return findCharacterIndexInLine(point, lineInfo: (closestLine, closestOrigin, closestLineIndex)) 522 | } 523 | 524 | // MARK: - Private Text Index Helpers 525 | 526 | private func findLineContainingPoint( 527 | _ point: CGPoint, 528 | ctFrame: CTFrame 529 | ) -> (line: CTLine, origin: CGPoint, index: Int)? { 530 | let lines = CTFrameGetLines(ctFrame) as [AnyObject] 531 | var lineOrigins = [CGPoint](repeating: .zero, count: lines.count) 532 | CTFrameGetLineOrigins(ctFrame, CFRange(location: 0, length: 0), &lineOrigins) 533 | 534 | for i in 0 ..< lines.count { 535 | let origin = lineOrigins[i] 536 | var ascent: CGFloat = 0 537 | var descent: CGFloat = 0 538 | var leading: CGFloat = 0 539 | 540 | let line = lines[i] as! CTLine 541 | let lineWidth = CTLineGetTypographicBounds(line, &ascent, &descent, &leading) 542 | let lineHeight = ascent + descent + leading 543 | 544 | let lineRect = CGRect( 545 | x: origin.x, 546 | y: origin.y - descent, 547 | width: lineWidth, 548 | height: lineHeight 549 | ) 550 | 551 | if point.y >= lineRect.minY, point.y <= lineRect.maxY { 552 | return (line: line, origin: origin, index: i) 553 | } 554 | } 555 | 556 | return nil 557 | } 558 | 559 | private func findCharacterIndexInLine( 560 | _ point: CGPoint, 561 | lineInfo: (line: CTLine, origin: CGPoint, index: Int) 562 | ) -> Int { 563 | let line = lineInfo.line 564 | let lineOrigin = lineInfo.origin 565 | let lineRange = CTLineGetStringRange(line) 566 | 567 | if point.x <= lineOrigin.x { 568 | return lineRange.location 569 | } 570 | 571 | for characterOffset in 0 ..< lineRange.length { 572 | let characterIndex = lineRange.location + characterOffset 573 | let positionOffset = CTLineGetOffsetForStringIndex(line, characterIndex, nil) 574 | 575 | if positionOffset >= point.x - lineOrigin.x { 576 | let distanceToNextChar = positionOffset - (point.x - lineOrigin.x) 577 | if characterOffset > 0 { 578 | let previousCharIndex = characterIndex - 1 579 | let previousPositionOffset = CTLineGetOffsetForStringIndex(line, previousCharIndex, nil) 580 | let distanceToPrevChar = (point.x - lineOrigin.x) - previousPositionOffset 581 | if distanceToNextChar > distanceToPrevChar { 582 | return previousCharIndex 583 | } 584 | } 585 | return characterIndex 586 | } 587 | } 588 | 589 | return lineRange.location + lineRange.length 590 | } 591 | } 592 | -------------------------------------------------------------------------------- /LitextSamples/LitextSamples.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | EDD2ED3C2D64B827006C1403 /* Litext in Frameworks */ = {isa = PBXBuildFile; productRef = EDD2ED3B2D64B827006C1403 /* Litext */; }; 11 | EDD2ED3F2D64B832006C1403 /* Litext in Frameworks */ = {isa = PBXBuildFile; productRef = EDD2ED3E2D64B832006C1403 /* Litext */; }; 12 | /* End PBXBuildFile section */ 13 | 14 | /* Begin PBXFileReference section */ 15 | EDD2ECEB2D64B75B006C1403 /* LitextSamples.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LitextSamples.app; sourceTree = BUILT_PRODUCTS_DIR; }; 16 | EDD2ED2B2D64B803006C1403 /* LitextSampleMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LitextSampleMac.app; sourceTree = BUILT_PRODUCTS_DIR; }; 17 | /* End PBXFileReference section */ 18 | 19 | /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 20 | 508E02F12D9460870052EB6A /* Exceptions for "LitextSampleMac" folder in "LitextSampleMac" target */ = { 21 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 22 | membershipExceptions = ( 23 | Info.plist, 24 | ); 25 | target = EDD2ED2A2D64B803006C1403 /* LitextSampleMac */; 26 | }; 27 | EDD2ECFD2D64B75C006C1403 /* Exceptions for "LitextSamples" folder in "LitextSamples" target */ = { 28 | isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 29 | membershipExceptions = ( 30 | Info.plist, 31 | ); 32 | target = EDD2ECEA2D64B75B006C1403 /* LitextSamples */; 33 | }; 34 | /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 35 | 36 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 37 | EDD2ECED2D64B75B006C1403 /* LitextSamples */ = { 38 | isa = PBXFileSystemSynchronizedRootGroup; 39 | exceptions = ( 40 | EDD2ECFD2D64B75C006C1403 /* Exceptions for "LitextSamples" folder in "LitextSamples" target */, 41 | ); 42 | path = LitextSamples; 43 | sourceTree = ""; 44 | }; 45 | EDD2ED2C2D64B803006C1403 /* LitextSampleMac */ = { 46 | isa = PBXFileSystemSynchronizedRootGroup; 47 | exceptions = ( 48 | 508E02F12D9460870052EB6A /* Exceptions for "LitextSampleMac" folder in "LitextSampleMac" target */, 49 | ); 50 | path = LitextSampleMac; 51 | sourceTree = ""; 52 | }; 53 | /* End PBXFileSystemSynchronizedRootGroup section */ 54 | 55 | /* Begin PBXFrameworksBuildPhase section */ 56 | EDD2ECE82D64B75B006C1403 /* Frameworks */ = { 57 | isa = PBXFrameworksBuildPhase; 58 | buildActionMask = 2147483647; 59 | files = ( 60 | EDD2ED3C2D64B827006C1403 /* Litext in Frameworks */, 61 | ); 62 | runOnlyForDeploymentPostprocessing = 0; 63 | }; 64 | EDD2ED282D64B803006C1403 /* Frameworks */ = { 65 | isa = PBXFrameworksBuildPhase; 66 | buildActionMask = 2147483647; 67 | files = ( 68 | EDD2ED3F2D64B832006C1403 /* Litext in Frameworks */, 69 | ); 70 | runOnlyForDeploymentPostprocessing = 0; 71 | }; 72 | /* End PBXFrameworksBuildPhase section */ 73 | 74 | /* Begin PBXGroup section */ 75 | EDD2ECE22D64B75B006C1403 = { 76 | isa = PBXGroup; 77 | children = ( 78 | EDD2ECED2D64B75B006C1403 /* LitextSamples */, 79 | EDD2ED2C2D64B803006C1403 /* LitextSampleMac */, 80 | EDD2ED3D2D64B832006C1403 /* Frameworks */, 81 | EDD2ECEC2D64B75B006C1403 /* Products */, 82 | ); 83 | sourceTree = ""; 84 | }; 85 | EDD2ECEC2D64B75B006C1403 /* Products */ = { 86 | isa = PBXGroup; 87 | children = ( 88 | EDD2ECEB2D64B75B006C1403 /* LitextSamples.app */, 89 | EDD2ED2B2D64B803006C1403 /* LitextSampleMac.app */, 90 | ); 91 | name = Products; 92 | sourceTree = ""; 93 | }; 94 | EDD2ED3D2D64B832006C1403 /* Frameworks */ = { 95 | isa = PBXGroup; 96 | children = ( 97 | ); 98 | name = Frameworks; 99 | sourceTree = ""; 100 | }; 101 | /* End PBXGroup section */ 102 | 103 | /* Begin PBXNativeTarget section */ 104 | EDD2ECEA2D64B75B006C1403 /* LitextSamples */ = { 105 | isa = PBXNativeTarget; 106 | buildConfigurationList = EDD2ECFE2D64B75C006C1403 /* Build configuration list for PBXNativeTarget "LitextSamples" */; 107 | buildPhases = ( 108 | EDD2ECE72D64B75B006C1403 /* Sources */, 109 | EDD2ECE82D64B75B006C1403 /* Frameworks */, 110 | EDD2ECE92D64B75B006C1403 /* Resources */, 111 | ); 112 | buildRules = ( 113 | ); 114 | dependencies = ( 115 | ); 116 | fileSystemSynchronizedGroups = ( 117 | EDD2ECED2D64B75B006C1403 /* LitextSamples */, 118 | ); 119 | name = LitextSamples; 120 | packageProductDependencies = ( 121 | EDD2ED3B2D64B827006C1403 /* Litext */, 122 | ); 123 | productName = LitextSamples; 124 | productReference = EDD2ECEB2D64B75B006C1403 /* LitextSamples.app */; 125 | productType = "com.apple.product-type.application"; 126 | }; 127 | EDD2ED2A2D64B803006C1403 /* LitextSampleMac */ = { 128 | isa = PBXNativeTarget; 129 | buildConfigurationList = EDD2ED372D64B804006C1403 /* Build configuration list for PBXNativeTarget "LitextSampleMac" */; 130 | buildPhases = ( 131 | EDD2ED272D64B803006C1403 /* Sources */, 132 | EDD2ED282D64B803006C1403 /* Frameworks */, 133 | EDD2ED292D64B803006C1403 /* Resources */, 134 | ); 135 | buildRules = ( 136 | ); 137 | dependencies = ( 138 | ); 139 | fileSystemSynchronizedGroups = ( 140 | EDD2ED2C2D64B803006C1403 /* LitextSampleMac */, 141 | ); 142 | name = LitextSampleMac; 143 | packageProductDependencies = ( 144 | EDD2ED3E2D64B832006C1403 /* Litext */, 145 | ); 146 | productName = LitextSampleMac; 147 | productReference = EDD2ED2B2D64B803006C1403 /* LitextSampleMac.app */; 148 | productType = "com.apple.product-type.application"; 149 | }; 150 | /* End PBXNativeTarget section */ 151 | 152 | /* Begin PBXProject section */ 153 | EDD2ECE32D64B75B006C1403 /* Project object */ = { 154 | isa = PBXProject; 155 | attributes = { 156 | BuildIndependentTargetsInParallel = 1; 157 | LastSwiftUpdateCheck = 1620; 158 | LastUpgradeCheck = 1620; 159 | TargetAttributes = { 160 | EDD2ECEA2D64B75B006C1403 = { 161 | CreatedOnToolsVersion = 16.2; 162 | }; 163 | EDD2ED2A2D64B803006C1403 = { 164 | CreatedOnToolsVersion = 16.2; 165 | }; 166 | }; 167 | }; 168 | buildConfigurationList = EDD2ECE62D64B75B006C1403 /* Build configuration list for PBXProject "LitextSamples" */; 169 | developmentRegion = en; 170 | hasScannedForEncodings = 0; 171 | knownRegions = ( 172 | en, 173 | Base, 174 | ); 175 | mainGroup = EDD2ECE22D64B75B006C1403; 176 | minimizedProjectReferenceProxies = 1; 177 | packageReferences = ( 178 | EDD2ED3A2D64B827006C1403 /* XCLocalSwiftPackageReference "../../litext" */, 179 | ); 180 | preferredProjectObjectVersion = 77; 181 | productRefGroup = EDD2ECEC2D64B75B006C1403 /* Products */; 182 | projectDirPath = ""; 183 | projectRoot = ""; 184 | targets = ( 185 | EDD2ECEA2D64B75B006C1403 /* LitextSamples */, 186 | EDD2ED2A2D64B803006C1403 /* LitextSampleMac */, 187 | ); 188 | }; 189 | /* End PBXProject section */ 190 | 191 | /* Begin PBXResourcesBuildPhase section */ 192 | EDD2ECE92D64B75B006C1403 /* Resources */ = { 193 | isa = PBXResourcesBuildPhase; 194 | buildActionMask = 2147483647; 195 | files = ( 196 | ); 197 | runOnlyForDeploymentPostprocessing = 0; 198 | }; 199 | EDD2ED292D64B803006C1403 /* Resources */ = { 200 | isa = PBXResourcesBuildPhase; 201 | buildActionMask = 2147483647; 202 | files = ( 203 | ); 204 | runOnlyForDeploymentPostprocessing = 0; 205 | }; 206 | /* End PBXResourcesBuildPhase section */ 207 | 208 | /* Begin PBXSourcesBuildPhase section */ 209 | EDD2ECE72D64B75B006C1403 /* Sources */ = { 210 | isa = PBXSourcesBuildPhase; 211 | buildActionMask = 2147483647; 212 | files = ( 213 | ); 214 | runOnlyForDeploymentPostprocessing = 0; 215 | }; 216 | EDD2ED272D64B803006C1403 /* Sources */ = { 217 | isa = PBXSourcesBuildPhase; 218 | buildActionMask = 2147483647; 219 | files = ( 220 | ); 221 | runOnlyForDeploymentPostprocessing = 0; 222 | }; 223 | /* End PBXSourcesBuildPhase section */ 224 | 225 | /* Begin XCBuildConfiguration section */ 226 | EDD2ECFF2D64B75C006C1403 /* Debug */ = { 227 | isa = XCBuildConfiguration; 228 | buildSettings = { 229 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 230 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 231 | CODE_SIGN_ENTITLEMENTS = LitextSamples/LitextSamples.entitlements; 232 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; 233 | CODE_SIGN_STYLE = Automatic; 234 | CURRENT_PROJECT_VERSION = 1; 235 | DEVELOPMENT_TEAM = 964G86XT2P; 236 | GENERATE_INFOPLIST_FILE = YES; 237 | INFOPLIST_FILE = LitextSamples/Info.plist; 238 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 239 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 240 | INFOPLIST_KEY_UIMainStoryboardFile = Main; 241 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 242 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 243 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 244 | LD_RUNPATH_SEARCH_PATHS = ( 245 | "$(inherited)", 246 | "@executable_path/Frameworks", 247 | ); 248 | MACOSX_DEPLOYMENT_TARGET = 11.0; 249 | MARKETING_VERSION = 1.0; 250 | PRODUCT_BUNDLE_IDENTIFIER = wiki.qaq.litext; 251 | PRODUCT_NAME = "$(TARGET_NAME)"; 252 | REGISTER_APP_GROUPS = NO; 253 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 254 | SUPPORTS_MACCATALYST = YES; 255 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 256 | SWIFT_EMIT_LOC_STRINGS = YES; 257 | SWIFT_VERSION = 5.0; 258 | TARGETED_DEVICE_FAMILY = "1,2"; 259 | }; 260 | name = Debug; 261 | }; 262 | EDD2ED002D64B75C006C1403 /* Release */ = { 263 | isa = XCBuildConfiguration; 264 | buildSettings = { 265 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 266 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 267 | CODE_SIGN_ENTITLEMENTS = LitextSamples/LitextSamples.entitlements; 268 | "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; 269 | CODE_SIGN_STYLE = Automatic; 270 | CURRENT_PROJECT_VERSION = 1; 271 | DEVELOPMENT_TEAM = 964G86XT2P; 272 | GENERATE_INFOPLIST_FILE = YES; 273 | INFOPLIST_FILE = LitextSamples/Info.plist; 274 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 275 | INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; 276 | INFOPLIST_KEY_UIMainStoryboardFile = Main; 277 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 278 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 279 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 280 | LD_RUNPATH_SEARCH_PATHS = ( 281 | "$(inherited)", 282 | "@executable_path/Frameworks", 283 | ); 284 | MACOSX_DEPLOYMENT_TARGET = 11.0; 285 | MARKETING_VERSION = 1.0; 286 | PRODUCT_BUNDLE_IDENTIFIER = wiki.qaq.litext; 287 | PRODUCT_NAME = "$(TARGET_NAME)"; 288 | REGISTER_APP_GROUPS = NO; 289 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; 290 | SUPPORTS_MACCATALYST = YES; 291 | SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; 292 | SWIFT_EMIT_LOC_STRINGS = YES; 293 | SWIFT_VERSION = 5.0; 294 | TARGETED_DEVICE_FAMILY = "1,2"; 295 | }; 296 | name = Release; 297 | }; 298 | EDD2ED012D64B75C006C1403 /* Debug */ = { 299 | isa = XCBuildConfiguration; 300 | buildSettings = { 301 | ALWAYS_SEARCH_USER_PATHS = NO; 302 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 303 | CLANG_ANALYZER_NONNULL = YES; 304 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 305 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 306 | CLANG_ENABLE_MODULES = YES; 307 | CLANG_ENABLE_OBJC_ARC = YES; 308 | CLANG_ENABLE_OBJC_WEAK = YES; 309 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 310 | CLANG_WARN_BOOL_CONVERSION = YES; 311 | CLANG_WARN_COMMA = YES; 312 | CLANG_WARN_CONSTANT_CONVERSION = YES; 313 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 314 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 315 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 316 | CLANG_WARN_EMPTY_BODY = YES; 317 | CLANG_WARN_ENUM_CONVERSION = YES; 318 | CLANG_WARN_INFINITE_RECURSION = YES; 319 | CLANG_WARN_INT_CONVERSION = YES; 320 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 321 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 322 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 323 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 324 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 325 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 326 | CLANG_WARN_STRICT_PROTOTYPES = YES; 327 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 328 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 329 | CLANG_WARN_UNREACHABLE_CODE = YES; 330 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 331 | COPY_PHASE_STRIP = NO; 332 | DEBUG_INFORMATION_FORMAT = dwarf; 333 | ENABLE_STRICT_OBJC_MSGSEND = YES; 334 | ENABLE_TESTABILITY = YES; 335 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 336 | GCC_C_LANGUAGE_STANDARD = gnu17; 337 | GCC_DYNAMIC_NO_PIC = NO; 338 | GCC_NO_COMMON_BLOCKS = YES; 339 | GCC_OPTIMIZATION_LEVEL = 0; 340 | GCC_PREPROCESSOR_DEFINITIONS = ( 341 | "DEBUG=1", 342 | "$(inherited)", 343 | ); 344 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 345 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 346 | GCC_WARN_UNDECLARED_SELECTOR = YES; 347 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 348 | GCC_WARN_UNUSED_FUNCTION = YES; 349 | GCC_WARN_UNUSED_VARIABLE = YES; 350 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 351 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 352 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 353 | MTL_FAST_MATH = YES; 354 | ONLY_ACTIVE_ARCH = YES; 355 | SDKROOT = iphoneos; 356 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 357 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 358 | }; 359 | name = Debug; 360 | }; 361 | EDD2ED022D64B75C006C1403 /* Release */ = { 362 | isa = XCBuildConfiguration; 363 | buildSettings = { 364 | ALWAYS_SEARCH_USER_PATHS = NO; 365 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 366 | CLANG_ANALYZER_NONNULL = YES; 367 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 368 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 369 | CLANG_ENABLE_MODULES = YES; 370 | CLANG_ENABLE_OBJC_ARC = YES; 371 | CLANG_ENABLE_OBJC_WEAK = YES; 372 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 373 | CLANG_WARN_BOOL_CONVERSION = YES; 374 | CLANG_WARN_COMMA = YES; 375 | CLANG_WARN_CONSTANT_CONVERSION = YES; 376 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 377 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 378 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 379 | CLANG_WARN_EMPTY_BODY = YES; 380 | CLANG_WARN_ENUM_CONVERSION = YES; 381 | CLANG_WARN_INFINITE_RECURSION = YES; 382 | CLANG_WARN_INT_CONVERSION = YES; 383 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 384 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 385 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 386 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 387 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 388 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 389 | CLANG_WARN_STRICT_PROTOTYPES = YES; 390 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 391 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 392 | CLANG_WARN_UNREACHABLE_CODE = YES; 393 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 394 | COPY_PHASE_STRIP = NO; 395 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 396 | ENABLE_NS_ASSERTIONS = NO; 397 | ENABLE_STRICT_OBJC_MSGSEND = YES; 398 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 399 | GCC_C_LANGUAGE_STANDARD = gnu17; 400 | GCC_NO_COMMON_BLOCKS = YES; 401 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 402 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 403 | GCC_WARN_UNDECLARED_SELECTOR = YES; 404 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 405 | GCC_WARN_UNUSED_FUNCTION = YES; 406 | GCC_WARN_UNUSED_VARIABLE = YES; 407 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 408 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 409 | MTL_ENABLE_DEBUG_INFO = NO; 410 | MTL_FAST_MATH = YES; 411 | SDKROOT = iphoneos; 412 | SWIFT_COMPILATION_MODE = wholemodule; 413 | VALIDATE_PRODUCT = YES; 414 | }; 415 | name = Release; 416 | }; 417 | EDD2ED382D64B804006C1403 /* Debug */ = { 418 | isa = XCBuildConfiguration; 419 | buildSettings = { 420 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 421 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 422 | CODE_SIGN_ENTITLEMENTS = LitextSampleMac/LitextSampleMac.entitlements; 423 | CODE_SIGN_STYLE = Automatic; 424 | COMBINE_HIDPI_IMAGES = YES; 425 | CURRENT_PROJECT_VERSION = 1; 426 | DEVELOPMENT_TEAM = ""; 427 | ENABLE_HARDENED_RUNTIME = YES; 428 | GENERATE_INFOPLIST_FILE = YES; 429 | INFOPLIST_FILE = LitextSampleMac/Info.plist; 430 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 431 | INFOPLIST_KEY_NSMainStoryboardFile = Main; 432 | INFOPLIST_KEY_NSPrincipalClass = NSApplication; 433 | LD_RUNPATH_SEARCH_PATHS = ( 434 | "$(inherited)", 435 | "@executable_path/../Frameworks", 436 | ); 437 | MACOSX_DEPLOYMENT_TARGET = 12.0; 438 | MARKETING_VERSION = 1.0; 439 | PRODUCT_BUNDLE_IDENTIFIER = me.ktiays.LitextSampleMac; 440 | PRODUCT_NAME = "$(TARGET_NAME)"; 441 | SDKROOT = macosx; 442 | SWIFT_EMIT_LOC_STRINGS = YES; 443 | SWIFT_VERSION = 5.0; 444 | }; 445 | name = Debug; 446 | }; 447 | EDD2ED392D64B804006C1403 /* Release */ = { 448 | isa = XCBuildConfiguration; 449 | buildSettings = { 450 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 451 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 452 | CODE_SIGN_ENTITLEMENTS = LitextSampleMac/LitextSampleMac.entitlements; 453 | CODE_SIGN_STYLE = Automatic; 454 | COMBINE_HIDPI_IMAGES = YES; 455 | CURRENT_PROJECT_VERSION = 1; 456 | DEVELOPMENT_TEAM = ""; 457 | ENABLE_HARDENED_RUNTIME = YES; 458 | GENERATE_INFOPLIST_FILE = YES; 459 | INFOPLIST_FILE = LitextSampleMac/Info.plist; 460 | INFOPLIST_KEY_NSHumanReadableCopyright = ""; 461 | INFOPLIST_KEY_NSMainStoryboardFile = Main; 462 | INFOPLIST_KEY_NSPrincipalClass = NSApplication; 463 | LD_RUNPATH_SEARCH_PATHS = ( 464 | "$(inherited)", 465 | "@executable_path/../Frameworks", 466 | ); 467 | MACOSX_DEPLOYMENT_TARGET = 12.0; 468 | MARKETING_VERSION = 1.0; 469 | PRODUCT_BUNDLE_IDENTIFIER = me.ktiays.LitextSampleMac; 470 | PRODUCT_NAME = "$(TARGET_NAME)"; 471 | SDKROOT = macosx; 472 | SWIFT_EMIT_LOC_STRINGS = YES; 473 | SWIFT_VERSION = 5.0; 474 | }; 475 | name = Release; 476 | }; 477 | /* End XCBuildConfiguration section */ 478 | 479 | /* Begin XCConfigurationList section */ 480 | EDD2ECE62D64B75B006C1403 /* Build configuration list for PBXProject "LitextSamples" */ = { 481 | isa = XCConfigurationList; 482 | buildConfigurations = ( 483 | EDD2ED012D64B75C006C1403 /* Debug */, 484 | EDD2ED022D64B75C006C1403 /* Release */, 485 | ); 486 | defaultConfigurationIsVisible = 0; 487 | defaultConfigurationName = Release; 488 | }; 489 | EDD2ECFE2D64B75C006C1403 /* Build configuration list for PBXNativeTarget "LitextSamples" */ = { 490 | isa = XCConfigurationList; 491 | buildConfigurations = ( 492 | EDD2ECFF2D64B75C006C1403 /* Debug */, 493 | EDD2ED002D64B75C006C1403 /* Release */, 494 | ); 495 | defaultConfigurationIsVisible = 0; 496 | defaultConfigurationName = Release; 497 | }; 498 | EDD2ED372D64B804006C1403 /* Build configuration list for PBXNativeTarget "LitextSampleMac" */ = { 499 | isa = XCConfigurationList; 500 | buildConfigurations = ( 501 | EDD2ED382D64B804006C1403 /* Debug */, 502 | EDD2ED392D64B804006C1403 /* Release */, 503 | ); 504 | defaultConfigurationIsVisible = 0; 505 | defaultConfigurationName = Release; 506 | }; 507 | /* End XCConfigurationList section */ 508 | 509 | /* Begin XCLocalSwiftPackageReference section */ 510 | EDD2ED3A2D64B827006C1403 /* XCLocalSwiftPackageReference "../../litext" */ = { 511 | isa = XCLocalSwiftPackageReference; 512 | relativePath = ../../litext; 513 | }; 514 | /* End XCLocalSwiftPackageReference section */ 515 | 516 | /* Begin XCSwiftPackageProductDependency section */ 517 | EDD2ED3B2D64B827006C1403 /* Litext */ = { 518 | isa = XCSwiftPackageProductDependency; 519 | productName = Litext; 520 | }; 521 | EDD2ED3E2D64B832006C1403 /* Litext */ = { 522 | isa = XCSwiftPackageProductDependency; 523 | package = EDD2ED3A2D64B827006C1403 /* XCLocalSwiftPackageReference "../../litext" */; 524 | productName = Litext; 525 | }; 526 | /* End XCSwiftPackageProductDependency section */ 527 | }; 528 | rootObject = EDD2ECE32D64B75B006C1403 /* Project object */; 529 | } 530 | --------------------------------------------------------------------------------