├── 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 | 
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 | 
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 |
--------------------------------------------------------------------------------