// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit public extension UISearchBar { var textField: UITextField? { return searchTextField } } // MARK: - public extension UITextField { func acceptAutocorrectSuggestion() { inputDelegate?.selectionWillChange(self) inputDelegate?.selectionDidChange(self) } } // MARK: - public extension UITextView { func acceptAutocorrectSuggestion() { // https://stackoverflow.com/a/27865136/4509555 inputDelegate?.selectionWillChange(self) inputDelegate?.selectionDidChange(self) } func characterIndex(of location: CGPoint) -> Int? { return textContainer.characterIndex(of: location, textStorage: textStorage, layoutManager: layoutManager) } } extension UITextView { public func disableAiWritingTools() { if #available(iOS 18, *) { self.writingToolsBehavior = .none } } } extension UITextField { public func disableAiWritingTools() { if #available(iOS 18, *) { self.writingToolsBehavior = .none } } } extension UISearchBar { public func disableAiWritingTools() { if #available(iOS 18, *) { self.writingToolsBehavior = .none } } } // MARK: - public extension NSTextContainer { func characterIndex( of location: CGPoint, textStorage: NSTextStorage, layoutManager: NSLayoutManager, ) -> Int? { guard textStorage.length > 0 else { return nil } let glyphRange = layoutManager.glyphRange(for: self) let boundingRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: self) guard boundingRect.contains(location) else { return nil } let glyphIndex = layoutManager.glyphIndex(for: location, in: self) // We have the _closest_ index, but that doesn't mean we tapped in a glyph. // Check that directly. // This will catch the below case, where "*" is the tap location: // // This is the first line that is long. // Tap on the second line. * // // The bounding rect includes the empty space below the first line, // but the tap doesn't actually lie on any glyph. let glyphRect = layoutManager.boundingRect(forGlyphRange: NSRange(location: glyphIndex, length: 1), in: self) guard glyphRect.contains(location) else { return nil } let characterIndex = layoutManager.characterIndexForGlyph(at: glyphIndex) return characterIndex } func boundingRects( ofCharacterRanges ranges: [NSRange], textStorage: NSTextStorage, layoutManager: NSLayoutManager, ) -> [CGRect] { return boundingRects( ofCharacterRanges: ranges, rangeMap: { return $0 }, textStorage: textStorage, layoutManager: layoutManager, transform: { rect, _ in return rect }, ) } func boundingRects( ofCharacterRanges ranges: [T], rangeMap: (T) -> NSRange, textStorage: NSTextStorage, layoutManager: NSLayoutManager, textContainerInsets: UIEdgeInsets = .zero, transform: @escaping (CGRect, T) -> R, ) -> [R] { return ranges.flatMap { (value: T) -> [R] in let range = rangeMap(value) guard textStorage.length >= range.upperBound else { return [] } let glyphRange = layoutManager.glyphRange(forCharacterRange: range, actualCharacterRange: nil) var perLineResults = [R]() layoutManager.enumerateEnclosingRects( forGlyphRange: glyphRange, withinSelectedGlyphRange: NSRange(location: NSNotFound, length: 0), in: self, ) { rect, stop in if rect.maxY > self.size.height { stop.pointee = true return } perLineResults.append(transform( rect.offsetBy(dx: textContainerInsets.left, dy: textContainerInsets.top), value, )) } return perLineResults } } } // MARK: - extension UILabel { public func characterIndex(of location: CGPoint) -> Int? { guard let (textContainer, textStorage, layoutManager) = makeMatchingTextContainer() else { return nil } return textContainer.characterIndex(of: location, textStorage: textStorage, layoutManager: layoutManager) } func boundingRects(ofCharacterRanges ranges: [NSRange]) -> [CGRect] { guard let (textContainer, textStorage, layoutManager) = makeMatchingTextContainer() else { return [] } return textContainer.boundingRects(ofCharacterRanges: ranges, textStorage: textStorage, layoutManager: layoutManager) } func boundingRects( ofCharacterRanges ranges: [T], rangeMap: (T) -> NSRange, transform: @escaping (CGRect, T) -> R, ) -> [R] { guard let (textContainer, textStorage, layoutManager) = makeMatchingTextContainer() else { return [] } return textContainer.boundingRects( ofCharacterRanges: ranges, rangeMap: rangeMap, textStorage: textStorage, layoutManager: layoutManager, transform: transform, ) } // This is somewhat inconsistent; labels with text alignments and who knows what // other attributes applied may not do a great job at identifying the index. // Eventually this should be removed in favor of using UITextView everywhere. private func makeMatchingTextContainer() -> (NSTextContainer, NSTextStorage, NSLayoutManager)? { let attrString: NSAttributedString if let attributedText { attrString = attributedText } else if let text { attrString = NSAttributedString(string: text, attributes: [.font: self.font as Any]) } else { return nil } let textStorage = NSTextStorage(attributedString: attrString) let layoutManager = NSLayoutManager() textStorage.addLayoutManager(layoutManager) let textContainer = NSTextContainer(size: self.bounds.size) textContainer.lineFragmentPadding = 0 textContainer.maximumNumberOfLines = self.numberOfLines textContainer.lineBreakMode = self.lineBreakMode layoutManager.addTextContainer(textContainer) return (textContainer, textStorage, layoutManager) } public class func title1Label(text: String) -> UILabel { let label = UILabel() label.text = text label.textColor = .Signal.label label.font = UIFont.dynamicTypeTitle1Clamped.semibold() label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping label.textAlignment = .center return label } public class func title2Label(text: String) -> UILabel { let label = UILabel() label.text = text label.textColor = .Signal.label label.font = UIFont.dynamicTypeTitle2Clamped.semibold() label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping label.textAlignment = .center return label } public class func headlineLabel(text: String, semibold: Bool = false) -> UILabel { let label = UILabel() label.text = text label.textColor = .Signal.label label.font = semibold ? .dynamicTypeHeadline.semibold() : .dynamicTypeSubheadline label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping label.textAlignment = .natural return label } public class func subheadlineLabel(text: String) -> UILabel { let label = UILabel() label.text = text label.textColor = .Signal.label label.font = .dynamicTypeSubheadline label.numberOfLines = 0 label.lineBreakMode = .byWordWrapping label.textAlignment = .natural return label } public class func explanationTextLabel(text: String) -> UILabel { let label = UILabel() label.textColor = .Signal.secondaryLabel label.font = .dynamicTypeBodyClamped label.text = text label.numberOfLines = 0 label.textAlignment = .center label.lineBreakMode = .byWordWrapping return label } } // MARK: - public extension NSTextAlignment { static var trailing: NSTextAlignment { CurrentAppContext().isRTL ? .left : .right } } // MARK: - extension NSTextAlignment: @retroactive CustomStringConvertible { public var description: String { switch self { case .left: return "left" case .center: return "center" case .right: return "right" case .justified: return "justified" case .natural: return "natural" @unknown default: return "unknown" } } }