// // TextView.swift // TGUIKit // // Created by keepcoder on 15/09/16. // Copyright © 2016 Telegram. All rights reserved. // import Cocoa import SwiftSignalKit import ColorPalette private func combineIntersectingRectangles(_ rectangles: [TextViewBlockQuote]) -> [TextViewBlockQuote] { if rectangles.isEmpty { return [] } else if rectangles.count == 1 { return rectangles } var result: [TextViewBlockQuote] = [] var big: TextViewBlockQuote = rectangles[0] var max_w: CGFloat = big.frame.width for i in 1 ..< rectangles.count { let current: TextViewBlockQuote = rectangles[i] if current.frame.intersects(big.frame) { big.frame = big.frame.union(current.frame) if i == rectangles.count - 1 { result.append(big) } } else { result.append(big) if i == rectangles.count - 1 { result.append(rectangles[i]) } else { big = rectangles[i] } } max_w = max(max_w, current.frame.width) } for value in result { value.frame = CGRect(origin: value.frame.origin, size: NSMakeSize(max_w, value.frame.height)) } return result } public struct TextInputAttributes { public static let bold = NSAttributedString.Key(rawValue: "Attribute__Bold") public static let italic = NSAttributedString.Key(rawValue: "Attribute__Italic") public static let monospace = NSAttributedString.Key(rawValue: "Attribute__Monospace") public static let strikethrough = NSAttributedString.Key(rawValue: "Attribute__Strikethrough") public static let underline = NSAttributedString.Key(rawValue: "Attribute__Underline") public static let textMention = NSAttributedString.Key(rawValue: "Attribute__TextMention") public static let textUrl = NSAttributedString.Key(rawValue: "Attribute__TextUrl") public static let spoiler = NSAttributedString.Key(rawValue: "Attribute__Spoiler") public static let customEmoji = NSAttributedString.Key(rawValue: "Attribute__CustomEmoji") public static let code = NSAttributedString.Key(rawValue: "Attribute__Code") public static let quote = NSAttributedString.Key(rawValue: "Attribute__Blockquote") public static let embedded = NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem") public static let allAttributes = [TextInputAttributes.bold, TextInputAttributes.italic, TextInputAttributes.monospace, TextInputAttributes.strikethrough, TextInputAttributes.underline, TextInputAttributes.textMention, TextInputAttributes.textUrl, TextInputAttributes.spoiler, TextInputAttributes.customEmoji, TextInputAttributes.code, TextInputAttributes.quote] } func findClosestRect(rectangles: [CGRect], point: CGPoint) -> CGRect? { var closestRect: CGRect? var closestYDistance = CGFloat.greatestFiniteMagnitude let point = point.offsetBy(dx: 0, dy: 5.0) for rect in rectangles { let point = NSMakePoint(point.x, point.y + rect.height / 2) if rect.contains(point) { return rect } let rectCenterY = rect.midY let yDistance = abs(point.y - rectCenterY) if yDistance < closestYDistance { closestYDistance = yDistance closestRect = rect } } return closestRect } private let quoteIcon: CGImage = { return NSImage(named: "Icon_Quote")!.precomposed(flipVertical: false) }() public enum LinkType { case plain case email case username case hashtag case command case stickerPack case emojiPack case inviteLink case code } public func isValidEmail(_ email: String) -> Bool { // Create a regular expression pattern to match an email address let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}" // Create a regular expression object from the pattern guard let regex = try? NSRegularExpression(pattern: emailRegex) else { return false } if let url = URL(string: email) { if url.host != nil || url.scheme != nil { return false } } // Check if the email string matches the pattern let range = NSRange(location: 0, length: email.utf16.count) return regex.firstMatch(in: email, options: [], range: range) != nil } private enum CornerType { case topLeft case topRight case bottomLeft case bottomRight } public extension NSAttributedString.Key { static let hexColorMark = NSAttributedString.Key("TextViewHexColorMarkAttribute") static let hexColorMarkDimensions = NSAttributedString.Key("TextViewHexColorMarkAttributeDimensions") } public final class TextViewBlockQuote { public var range: NSRange public var frame: CGRect public var colors: PeerNameColors.Colors public var isCode: Bool public var header: (TextNodeLayout, TextNode)? init(frame: CGRect, range: NSRange, colors: PeerNameColors.Colors, isCode: Bool, header: (TextNodeLayout, TextNode)?) { self.frame = frame self.range = range self.colors = colors self.isCode = isCode self.header = header } public var headerInset: CGFloat { if header != nil { return 20 } else { return 0 } } } public final class TextViewBlockQuoteData: NSObject { public let id: Int public let colors: PeerNameColors.Colors public let space: CGFloat public let isCode: Bool public let header: (TextNodeLayout, TextNode)? public let collapsable: Bool public init(id: Int, colors: PeerNameColors.Colors, isCode: Bool = false, space: CGFloat = 4, header: (TextNodeLayout, TextNode)? = nil, collapsable: Bool = false) { self.id = id self.colors = colors self.isCode = isCode self.space = space self.header = header self.collapsable = collapsable super.init() } var headerInset: CGFloat { if header != nil { return 20 } else { return 0 } } var minimumHeaderSize: CGFloat { if let header = header { return header.0.size.width + 20 } else { return 0 } } override public func isEqual(_ object: Any?) -> Bool { guard let other = object as? TextViewBlockQuoteData else { return false } if self.id != other.id { return false } if self.colors != other.colors { return false } if self.isCode != other.isCode { return false } if self.space != other.space { return false } return true } } private final class TextViewEmbeddedItem { let range: NSRange let frame: CGRect let item: AnyHashable init(range: NSRange, frame: CGRect, item: AnyHashable) { self.range = range self.frame = frame self.item = item } } private func drawFullCorner(context: CGContext, color: NSColor, at point: CGPoint, type: CornerType, radius: CGFloat) { context.setFillColor(color.cgColor) switch type { case .topLeft: context.clear(CGRect(origin: point, size: CGSize(width: radius, height: radius))) context.fillEllipse(in: CGRect(origin: point, size: CGSize(width: radius * 2.0, height: radius * 2.0))) case .topRight: context.clear(CGRect(origin: CGPoint(x: point.x - radius, y: point.y), size: CGSize(width: radius, height: radius))) context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x - radius * 2.0, y: point.y), size: CGSize(width: radius * 2.0, height: radius * 2.0))) case .bottomLeft: context.clear(CGRect(origin: CGPoint(x: point.x, y: point.y - radius), size: CGSize(width: radius, height: radius))) context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x, y: point.y - radius * 2.0), size: CGSize(width: radius * 2.0, height: radius * 2.0))) case .bottomRight: context.clear(CGRect(origin: CGPoint(x: point.x - radius, y: point.y - radius), size: CGSize(width: radius, height: radius))) context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x - radius * 2.0, y: point.y - radius * 2.0), size: CGSize(width: radius * 2.0, height: radius * 2.0))) } } private func drawConnectingCorner(context: CGContext, color: NSColor, at point: CGPoint, type: CornerType, radius: CGFloat) { context.setFillColor(color.cgColor) switch type { case .topLeft: context.fill(CGRect(origin: CGPoint(x: point.x - radius, y: point.y), size: CGSize(width: radius, height: radius))) context.setFillColor(NSColor.clear.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x - radius * 2.0, y: point.y), size: CGSize(width: radius * 2.0, height: radius * 2.0))) case .topRight: context.fill(CGRect(origin: CGPoint(x: point.x, y: point.y), size: CGSize(width: radius, height: radius))) context.setFillColor(NSColor.clear.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x, y: point.y), size: CGSize(width: radius * 2.0, height: radius * 2.0))) case .bottomLeft: context.fill(CGRect(origin: CGPoint(x: point.x - radius, y: point.y - radius), size: CGSize(width: radius, height: radius))) context.setFillColor(NSColor.clear.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x - radius * 2.0, y: point.y - radius * 2.0), size: CGSize(width: radius * 2.0, height: radius * 2.0))) case .bottomRight: context.fill(CGRect(origin: CGPoint(x: point.x, y: point.y - radius), size: CGSize(width: radius, height: radius))) context.setFillColor(NSColor.clear.cgColor) context.fillEllipse(in: CGRect(origin: CGPoint(x: point.x, y: point.y - radius * 2.0), size: CGSize(width: radius * 2.0, height: radius * 2.0))) } } public func generateRectsImage(color: NSColor, rects: [CGRect], inset: CGFloat, outerRadius: CGFloat, innerRadius: CGFloat, stroke: Bool = false, strokeWidth: CGFloat = 2.0, useModernPathCalculation: Bool) -> (CGPoint, CGImage?) { if rects.isEmpty { return (CGPoint(), nil) } var topLeft = rects[0].origin var bottomRight = CGPoint(x: rects[0].maxX, y: rects[0].maxY) for i in 1 ..< rects.count { topLeft.x = min(topLeft.x, rects[i].origin.x) topLeft.y = min(topLeft.y, rects[i].origin.y) bottomRight.x = max(bottomRight.x, rects[i].maxX) bottomRight.y = max(bottomRight.y, rects[i].maxY) } var drawingInset = inset if stroke { drawingInset += 2.0 } topLeft.x -= drawingInset topLeft.y -= drawingInset bottomRight.x += drawingInset * 2.0 bottomRight.y += drawingInset * 2.0 return (topLeft, generateImage(CGSize(width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y), rotatedContext: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(color.cgColor) context.setBlendMode(.copy) if useModernPathCalculation { if rects.count == 1 { let path = CGPath(roundedRect: rects[0].offsetBy(dx: -topLeft.x, dy: -topLeft.y), cornerWidth: outerRadius, cornerHeight: outerRadius, transform: nil) context.addPath(path) if stroke { context.setStrokeColor(color.cgColor) context.setLineWidth(strokeWidth) context.strokePath() } else { context.fillPath() } return } var combinedRects: [[CGRect]] = [] var currentRects: [CGRect] = [] for rect in rects { if rect.width.isZero { if !currentRects.isEmpty { combinedRects.append(currentRects) } currentRects.removeAll() } else { currentRects.append(rect) } } if !currentRects.isEmpty { combinedRects.append(currentRects) } for rects in combinedRects { var rects = rects.map { $0.insetBy(dx: -inset, dy: -inset).offsetBy(dx: -topLeft.x, dy: -topLeft.y) } let minRadius: CGFloat = 2.0 for _ in 0 ..< rects.count * rects.count { var hadChanges = false for i in 0 ..< rects.count - 1 { if rects[i].maxY > rects[i + 1].minY { let midY = floor((rects[i].maxY + rects[i + 1].minY) * 0.5) rects[i].size.height = midY - rects[i].minY rects[i + 1].origin.y = midY rects[i + 1].size.height = rects[i + 1].maxY - midY hadChanges = true } if rects[i].maxY >= rects[i + 1].minY && rects[i].insetBy(dx: 0.0, dy: 1.0).intersects(rects[i + 1]) { if abs(rects[i].minX - rects[i + 1].minX) < minRadius { let commonMinX = min(rects[i].origin.x, rects[i + 1].origin.x) if rects[i].origin.x != commonMinX { rects[i].origin.x = commonMinX hadChanges = true } if rects[i + 1].origin.x != commonMinX { rects[i + 1].origin.x = commonMinX hadChanges = true } } if abs(rects[i].maxX - rects[i + 1].maxX) < minRadius { let commonMaxX = max(rects[i].maxX, rects[i + 1].maxX) if rects[i].maxX != commonMaxX { rects[i].size.width = commonMaxX - rects[i].minX hadChanges = true } if rects[i + 1].maxX != commonMaxX { rects[i + 1].size.width = commonMaxX - rects[i + 1].minX hadChanges = true } } } } if !hadChanges { break } } context.move(to: CGPoint(x: rects[0].midX, y: rects[0].minY)) context.addLine(to: CGPoint(x: rects[0].maxX - outerRadius, y: rects[0].minY)) context.addArc(tangent1End: rects[0].topRight, tangent2End: CGPoint(x: rects[0].maxX, y: rects[0].minY + outerRadius), radius: outerRadius) context.addLine(to: CGPoint(x: rects[0].maxX, y: rects[0].midY)) for i in 0 ..< rects.count - 1 { let rect = rects[i] let next = rects[i + 1] if rect.maxX == next.maxX { context.addLine(to: CGPoint(x: next.maxX, y: next.midY)) } else { let nextRadius = min(outerRadius, floor(abs(rect.maxX - next.maxX) * 0.5)) context.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - nextRadius)) if next.maxX > rect.maxX { context.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), tangent2End: CGPoint(x: rect.maxX + nextRadius, y: rect.maxY), radius: nextRadius) context.addLine(to: CGPoint(x: next.maxX - nextRadius, y: next.minY)) } else { context.addArc(tangent1End: CGPoint(x: rect.maxX, y: rect.maxY), tangent2End: CGPoint(x: rect.maxX - nextRadius, y: rect.maxY), radius: nextRadius) context.addLine(to: CGPoint(x: next.maxX + nextRadius, y: next.minY)) } context.addArc(tangent1End: next.topRight, tangent2End: CGPoint(x: next.maxX, y: next.minY + nextRadius), radius: nextRadius) context.addLine(to: CGPoint(x: next.maxX, y: next.midY)) } } let last = rects[rects.count - 1] context.addLine(to: CGPoint(x: last.maxX, y: last.maxY - outerRadius)) context.addArc(tangent1End: last.bottomRight, tangent2End: CGPoint(x: last.maxX - outerRadius, y: last.maxY), radius: outerRadius) context.addLine(to: CGPoint(x: last.minX + outerRadius, y: last.maxY)) context.addArc(tangent1End: last.bottomLeft, tangent2End: CGPoint(x: last.minX, y: last.maxY - outerRadius), radius: outerRadius) for i in (1 ..< rects.count).reversed() { let rect = rects[i] let prev = rects[i - 1] if rect.minX == prev.minX { context.addLine(to: CGPoint(x: prev.minX, y: prev.midY)) } else { let prevRadius = min(outerRadius, floor(abs(rect.minX - prev.minX) * 0.5)) context.addLine(to: CGPoint(x: rect.minX, y: rect.minY + prevRadius)) if rect.minX < prev.minX { context.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY), tangent2End: CGPoint(x: rect.minX + prevRadius, y: rect.minY), radius: prevRadius) context.addLine(to: CGPoint(x: prev.minX - prevRadius, y: prev.maxY)) } else { context.addArc(tangent1End: CGPoint(x: rect.minX, y: rect.minY), tangent2End: CGPoint(x: rect.minX - prevRadius, y: rect.minY), radius: prevRadius) context.addLine(to: CGPoint(x: prev.minX + prevRadius, y: prev.maxY)) } context.addArc(tangent1End: prev.bottomLeft, tangent2End: CGPoint(x: prev.minX, y: prev.maxY - prevRadius), radius: prevRadius) context.addLine(to: CGPoint(x: prev.minX, y: prev.midY)) } } context.addLine(to: CGPoint(x: rects[0].minX, y: rects[0].minY + outerRadius)) context.addArc(tangent1End: rects[0].topLeft, tangent2End: CGPoint(x: rects[0].minX + outerRadius, y: rects[0].minY), radius: outerRadius) context.addLine(to: CGPoint(x: rects[0].midX, y: rects[0].minY)) if stroke { context.setStrokeColor(color.cgColor) context.setLineWidth(strokeWidth) context.strokePath() } else { context.fillPath() } } return } for i in 0 ..< rects.count { let rect = rects[i].insetBy(dx: -inset, dy: -inset) context.fill(rect.offsetBy(dx: -topLeft.x, dy: -topLeft.y)) } for i in 0 ..< rects.count { let rect = rects[i].insetBy(dx: -inset, dy: -inset).offsetBy(dx: -topLeft.x, dy: -topLeft.y) var previous: CGRect? if i != 0 { previous = rects[i - 1].insetBy(dx: -inset, dy: -inset).offsetBy(dx: -topLeft.x, dy: -topLeft.y) } var next: CGRect? if i != rects.count - 1 { next = rects[i + 1].insetBy(dx: -inset, dy: -inset).offsetBy(dx: -topLeft.x, dy: -topLeft.y) } if let previous = previous { if previous.contains(rect.topLeft) { if abs(rect.topLeft.x - previous.minX) >= innerRadius { var radius = innerRadius if let next = next { radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) } drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.topLeft.x, y: previous.maxY), type: .topLeft, radius: radius) } } else { drawFullCorner(context: context, color: color, at: rect.topLeft, type: .topLeft, radius: outerRadius) } if previous.contains(rect.topRight.offsetBy(dx: -1.0, dy: 0.0)) { if abs(rect.topRight.x - previous.maxX) >= innerRadius { var radius = innerRadius if let next = next { radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) } drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.topRight.x, y: previous.maxY), type: .topRight, radius: radius) } } else { drawFullCorner(context: context, color: color, at: rect.topRight, type: .topRight, radius: outerRadius) } } else { drawFullCorner(context: context, color: color, at: rect.topLeft, type: .topLeft, radius: outerRadius) drawFullCorner(context: context, color: color, at: rect.topRight, type: .topRight, radius: outerRadius) } if let next = next { if next.contains(rect.bottomLeft) { if abs(rect.bottomRight.x - next.maxX) >= innerRadius { var radius = innerRadius if let previous = previous { radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) } drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.bottomLeft.x, y: next.minY), type: .bottomLeft, radius: radius) } } else { drawFullCorner(context: context, color: color, at: rect.bottomLeft, type: .bottomLeft, radius: outerRadius) } if next.contains(rect.bottomRight.offsetBy(dx: -1.0, dy: 0.0)) { if abs(rect.bottomRight.x - next.maxX) >= innerRadius { var radius = innerRadius if let previous = previous { radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) } drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.bottomRight.x, y: next.minY), type: .bottomRight, radius: radius) } } else { drawFullCorner(context: context, color: color, at: rect.bottomRight, type: .bottomRight, radius: outerRadius) } } else { drawFullCorner(context: context, color: color, at: rect.bottomLeft, type: .bottomLeft, radius: outerRadius) drawFullCorner(context: context, color: color, at: rect.bottomRight, type: .bottomRight, radius: outerRadius) } } })) } private func generateRectsImage(color: NSColor, rects: [CGRect], inset: CGFloat, outerRadius: CGFloat, innerRadius: CGFloat) -> (CGPoint, CGImage?) { if rects.isEmpty { return (CGPoint(), nil) } var topLeft = rects[0].origin var bottomRight = CGPoint(x: rects[0].maxX, y: rects[0].maxY) for i in 1 ..< rects.count { topLeft.x = min(topLeft.x, rects[i].origin.x) topLeft.y = min(topLeft.y, rects[i].origin.y) bottomRight.x = max(bottomRight.x, rects[i].maxX) bottomRight.y = max(bottomRight.y, rects[i].maxY) } topLeft.x -= inset topLeft.y -= inset bottomRight.x += inset * 2 bottomRight.y += inset * 2 return (topLeft, generateImage(CGSize(width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.setFillColor(color.cgColor) context.setBlendMode(.copy) for i in 0 ..< rects.count { let rect = rects[i].insetBy(dx: -inset, dy: -inset) context.fill(rect.offsetBy(dx: -topLeft.x, dy: -topLeft.y)) } for i in 0 ..< rects.count { let rect = rects[i].insetBy(dx: -inset, dy: -inset).offsetBy(dx: -topLeft.x, dy: -topLeft.y) var previous: CGRect? if i != 0 { previous = rects[i - 1].insetBy(dx: -inset, dy: -inset).offsetBy(dx: -topLeft.x, dy: -topLeft.y) } var next: CGRect? if i != rects.count - 1 { next = rects[i + 1].insetBy(dx: -inset, dy: -inset).offsetBy(dx: -topLeft.x, dy: -topLeft.y) } if let previous = previous { if previous.contains(rect.topLeft) { if abs(rect.topLeft.x - previous.minX) >= innerRadius { var radius = innerRadius if let next = next { radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) } drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.topLeft.x, y: previous.maxY), type: .topLeft, radius: radius) } } else { drawFullCorner(context: context, color: color, at: rect.topLeft, type: .topLeft, radius: outerRadius) } if previous.contains(rect.topRight.offsetBy(dx: -1.0, dy: 0.0)) { if abs(rect.topRight.x - previous.maxX) >= innerRadius { var radius = innerRadius if let next = next { radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) } drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.topRight.x, y: previous.maxY), type: .topRight, radius: radius) } } else { drawFullCorner(context: context, color: color, at: rect.topRight, type: .topRight, radius: outerRadius) } } else { drawFullCorner(context: context, color: color, at: rect.topLeft, type: .topLeft, radius: outerRadius) drawFullCorner(context: context, color: color, at: rect.topRight, type: .topRight, radius: outerRadius) } if let next = next { if next.contains(rect.bottomLeft) { if abs(rect.bottomRight.x - next.maxX) >= innerRadius { var radius = innerRadius if let previous = previous { radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) } drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.bottomLeft.x, y: next.minY), type: .bottomLeft, radius: radius) } } else { drawFullCorner(context: context, color: color, at: rect.bottomLeft, type: .bottomLeft, radius: outerRadius) } if next.contains(rect.bottomRight.offsetBy(dx: -1.0, dy: 0.0)) { if abs(rect.bottomRight.x - next.maxX) >= innerRadius { var radius = innerRadius if let previous = previous { radius = min(radius, floor((next.minY - previous.maxY) / 2.0)) } drawConnectingCorner(context: context, color: color, at: CGPoint(x: rect.bottomRight.x, y: next.minY), type: .bottomRight, radius: radius) } } else { drawFullCorner(context: context, color: color, at: rect.bottomRight, type: .bottomRight, radius: outerRadius) } } else { drawFullCorner(context: context, color: color, at: rect.bottomLeft, type: .bottomLeft, radius: outerRadius) drawFullCorner(context: context, color: color, at: rect.bottomRight, type: .bottomRight, radius: outerRadius) } } })) } public enum LinkHoverValue { case entered(Any) case exited } public final class TextViewInteractions { public var processURL:(Any)->Void // link, isPresent public var copy:(()->Bool)? public var menuItems:((LinkType?)->Signal<[ContextMenuItem], NoError>)? public var isDomainLink:(Any, String?)->Bool public var makeLinkType:((Any, String))->LinkType public var localizeLinkCopy:(LinkType)-> String public var resolveLink:(Any)->String? public var copyAttributedString:(NSAttributedString)->Bool public var copyToClipboard:((String)->Void)? public var hoverOnLink: (LinkHoverValue)->Void public var topWindow:(()->Signal)? = nil public var translate:((String, Window)->ContextMenuItem?)? = nil public init(processURL:@escaping (Any)->Void = {_ in}, copy:(()-> Bool)? = nil, menuItems:((LinkType?)->Signal<[ContextMenuItem], NoError>)? = nil, isDomainLink:@escaping(Any, String?)->Bool = {_, _ in return true}, makeLinkType:@escaping((Any, String)) -> LinkType = {_ in return .plain}, localizeLinkCopy:@escaping(LinkType)-> String = {_ in return localizedString("Text.Copy")}, resolveLink: @escaping(Any)->String? = { _ in return nil }, copyAttributedString: @escaping(NSAttributedString)->Bool = { _ in return false}, copyToClipboard: ((String)->Void)? = nil, hoverOnLink: @escaping(LinkHoverValue)->Void = { _ in }, topWindow:(()->Signal)? = nil, translate:((String, Window)->ContextMenuItem?)? = nil) { self.processURL = processURL self.copy = copy self.menuItems = menuItems self.isDomainLink = isDomainLink self.makeLinkType = makeLinkType self.localizeLinkCopy = localizeLinkCopy self.resolveLink = resolveLink self.copyAttributedString = copyAttributedString self.copyToClipboard = copyToClipboard self.hoverOnLink = hoverOnLink self.topWindow = topWindow self.translate = translate } } struct TextViewStrikethrough { let color: NSColor let frame: NSRect init(color: NSColor, frame: NSRect) { self.color = color self.frame = frame } } public final class TextViewLine { public let line: CTLine public var frame: NSRect public let range: NSRange fileprivate let lineRange: NSRange public var penFlush: CGFloat public let isRTL: Bool let isBlocked: Bool let strikethrough:[TextViewStrikethrough] fileprivate let embeddedItems:[TextViewEmbeddedItem] fileprivate init(line: CTLine, frame: CGRect, range: NSRange, lineRange: NSRange, penFlush: CGFloat, isBlocked: Bool = false, isRTL: Bool = false, strikethrough: [TextViewStrikethrough] = [], embeddedItems:[TextViewEmbeddedItem] = []) { self.line = line self.frame = frame self.range = range self.penFlush = penFlush self.isBlocked = isBlocked self.strikethrough = strikethrough self.isRTL = isRTL self.embeddedItems = embeddedItems self.lineRange = lineRange } } public enum TextViewCutoutPosition { case TopLeft case TopRight case BottomRight } public struct TextViewCutout: Equatable { public var topLeft: CGSize? public var topRight: CGSize? public var bottomRight: CGSize? public init(topLeft: CGSize? = nil, topRight: CGSize? = nil, bottomRight: CGSize? = nil) { self.topLeft = topLeft self.topRight = topRight self.bottomRight = bottomRight } } private let defaultFont:NSFont = .normal(.text) public final class TextViewLayout : Equatable { public final class EmbeddedItem: Equatable { public let range: NSRange public let rect: CGRect public let value: AnyHashable public init(range: NSRange, rect: CGRect, value: AnyHashable) { self.range = range self.rect = rect.toScreenPixel self.value = value } public static func ==(lhs: EmbeddedItem, rhs: EmbeddedItem) -> Bool { if lhs.range != rhs.range { return false } if lhs.rect != rhs.rect { return false } if lhs.value != rhs.value { return false } return true } } public class Spoiler { public let range: NSRange public let color: NSColor public fileprivate(set) var isRevealed: Bool = false public init(range: NSRange, color: NSColor, isRevealed: Bool = false) { self.range = range self.color = color.withAlphaComponent(1.0) self.isRevealed = isRevealed } } public var mayItems: Bool = true public var selectWholeText: Bool = false public fileprivate(set) var attributedString:NSAttributedString public fileprivate(set) var constrainedWidth:CGFloat = 0 public var interactions:TextViewInteractions = TextViewInteractions() public var selectedRange:TextSelectedRange public var additionalSelections:[TextSelectedRange] = [] public var penFlush:CGFloat fileprivate var insets:NSSize = NSZeroSize public var lines:[TextViewLine] = [] public private(set) var blockQuotes: [TextViewBlockQuote] = [] public fileprivate(set) var isPerfectSized:Bool = true public var maximumNumberOfLines:Int32 public let truncationType:CTLineTruncationType public var cutout:TextViewCutout? public var mayBlocked: Bool = false fileprivate var blockImage:(CGPoint, CGImage?) = (CGPoint(), nil) public var maskBlockImage:(CGPoint, CGImage?) = (CGPoint(), nil) public fileprivate(set) var lineSpacing:CGFloat? public var hasBlock: Bool { return blockImage.1 != nil } public var hasBlockQuotes: Bool { return self.attributedString.containsAttribute(attributeName: TextInputAttributes.quote) is TextViewBlockQuoteData } public var lastLineIsQuote: Bool { if let lineRange = self.lines.last?.lineRange { return self.attributedString.attributedSubstring(from: lineRange).containsAttribute(attributeName: TextInputAttributes.quote) is TextViewBlockQuoteData } else { return false } } public var blockCollapsable: Bool { return (self.attributedString.containsAttribute(attributeName: TextInputAttributes.quote) as? TextViewBlockQuoteData)?.collapsable ?? false } public var string: String { return self.attributedString.string } public private(set) var layoutSize:NSSize = NSZeroSize public private(set) var perfectSize:NSSize = NSZeroSize public var alwaysStaticItems: Bool fileprivate var selectText: NSColor public var strokeLinks: Bool fileprivate var strokeRects: [(NSRect, NSColor)] = [] fileprivate var hexColorsRect: [(NSRect, NSColor, String)] = [] fileprivate var toolTipRects:[NSRect] = [] private let disableTooltips: Bool public fileprivate(set) var isBigEmoji: Bool = false fileprivate let spoilers:[Spoiler] private let onSpoilerReveal: ()->Void public private(set) var embeddedItems: [EmbeddedItem] = [] public var truncatingColor: NSColor? = nil public init(_ attributedString:NSAttributedString, constrainedWidth:CGFloat = 0, maximumNumberOfLines:Int32 = INT32_MAX, truncationType: CTLineTruncationType = .end, cutout:TextViewCutout? = nil, alignment:NSTextAlignment = .left, lineSpacing:CGFloat? = nil, selectText: NSColor = presentation.colors.selectText, strokeLinks: Bool = false, alwaysStaticItems: Bool = false, disableTooltips: Bool = true, mayItems: Bool = true, spoilerColor:NSColor = presentation.colors.text, isSpoilerRevealed: Bool = false, onSpoilerReveal: @escaping()->Void = {}, truncatingColor: NSColor? = nil) { self.truncationType = truncationType self.maximumNumberOfLines = maximumNumberOfLines self.cutout = cutout self.disableTooltips = disableTooltips self.attributedString = attributedString self.constrainedWidth = constrainedWidth self.selectText = selectText self.alwaysStaticItems = alwaysStaticItems self.selectedRange = TextSelectedRange(color: selectText) self.strokeLinks = strokeLinks self.mayItems = mayItems self.onSpoilerReveal = onSpoilerReveal self.truncatingColor = truncatingColor switch alignment { case .center: penFlush = 0.5 case .right: penFlush = 1.0 default: penFlush = 0.0 } self.lineSpacing = lineSpacing var spoilers: [Spoiler] = [] attributedString.enumerateAttribute(TextInputAttributes.spoiler, in: attributedString.range, options: .init(), using: { value, range, stop in if let _ = value { spoilers.append(.init(range: range, color: spoilerColor, isRevealed: isSpoilerRevealed)) } }) self.spoilers = spoilers } func revealSpoiler() -> Void { for spoiler in spoilers { spoiler.isRevealed = true } onSpoilerReveal() } public var selectedString: NSAttributedString { if selectedRange.range.isEmpty { return self.attributedString } else { return self.attributedString.attributedSubstring(from: selectedRange.range) } } public func dropLayoutSize() { self.layoutSize = .zero } public var numberOfLines: Int { return lines.count } public var lastLineIsRtl: Bool { return lines.last?.isRTL ?? false } public var lastLineIsBlock: Bool { return blockQuotes.contains(where: { blockQuote in if let line = lines.last { if blockQuote.frame.intersects(line.frame) { return true } } return false }) } public var isWholeRTL: Bool { return lines.allSatisfy({ $0.isRTL }) } public func isFirstRTL(count: Int) -> Bool { for i in 0 ..< count { if i < lines.count { if !lines[i].isRTL { return false } } else { break } } return true } public var firstLineWidth: CGFloat { return lines[0].frame.width } public var lastLineWidth: CGFloat { return lines[lines.count - 1].frame.width } public var firstLineHeight: CGFloat { return lines[0].frame.height } public var lastLineHeight: CGFloat { return lines[lines.count - 1].frame.height } func calculateLayout(isBigEmoji: Bool = false, lineSpacing: CGFloat? = nil, saveRTL: Bool = false) -> Void { self.isBigEmoji = isBigEmoji isPerfectSized = true self.insets = .zero let font: CTFont if attributedString.length != 0 { if let stringFont = attributedString.attribute(NSAttributedString.Key(kCTFontAttributeName as String), at: 0, effectiveRange: nil) { font = stringFont as! CTFont } else { font = defaultFont } } else { font = defaultFont } self.lines.removeAll() self.blockQuotes.removeAll() let fontAscent = CTFontGetAscent(font) let fontDescent = CTFontGetDescent(font) let fontLineHeight = floor(fontAscent + fontDescent) var fontLineSpacing:CGFloat = lineSpacing ?? floor(fontLineHeight * 0.12) let attributedString = self.attributedString var maybeTypesetter: CTTypesetter? let copy = attributedString.mutableCopy() as! NSMutableAttributedString copy.removeAttribute(.strikethroughStyle, range: copy.range) maybeTypesetter = CTTypesetterCreateWithAttributedString(copy as CFAttributedString) let typesetter = maybeTypesetter! var lastLineCharacterIndex: CFIndex = 0 var layoutSize = NSSize() var cutoutEnabled = false var cutoutMinY: CGFloat = 0.0 var cutoutMaxY: CGFloat = 0.0 var cutoutWidth: CGFloat = 0.0 var cutoutOffset: CGFloat = 0.0 var bottomCutoutEnabled = false var bottomCutoutSize = CGSize() if let topLeft = cutout?.topLeft { cutoutMinY = -fontLineSpacing cutoutMaxY = topLeft.height + fontLineSpacing cutoutWidth = topLeft.width cutoutOffset = cutoutWidth cutoutEnabled = true } else if let topRight = cutout?.topRight { cutoutMinY = -fontLineSpacing cutoutMaxY = topRight.height + fontLineSpacing cutoutWidth = topRight.width cutoutEnabled = true } if let bottomRight = cutout?.bottomRight { bottomCutoutSize = bottomRight bottomCutoutEnabled = true } let blockQuote:(Int)-> TextViewBlockQuoteData? = { index in return (attributedString.attribute(TextInputAttributes.quote, at: min(lastLineCharacterIndex, attributedString.length - 1), effectiveRange: nil) as? TextViewBlockQuoteData) } var first = true var breakInset: CGFloat = 0 var isWasPreformatted: Bool = false while true { var strikethroughs: [TextViewStrikethrough] = [] var embeddedItems: [TextViewEmbeddedItem] = [] func addEmbeddedItem(item: AnyHashable, line: CTLine, ascent: CGFloat, descent: CGFloat, startIndex: Int, endIndex: Int, rightInset: CGFloat = 0.0) { var secondaryLeftOffset: CGFloat = 0.0 let rawLeftOffset = CTLineGetOffsetForStringIndex(line, startIndex, &secondaryLeftOffset) var leftOffset = floor(rawLeftOffset) if !rawLeftOffset.isEqual(to: secondaryLeftOffset) { leftOffset = floor(secondaryLeftOffset) } var secondaryRightOffset: CGFloat = 0.0 let rawRightOffset = CTLineGetOffsetForStringIndex(line, endIndex, &secondaryRightOffset) var rightOffset = ceil(rawRightOffset) if !rawRightOffset.isEqual(to: secondaryRightOffset) { rightOffset = ceil(secondaryRightOffset) } if abs(rightOffset - leftOffset) < 150, abs(rightOffset - leftOffset) > 8 { let x = floor(min(leftOffset, rightOffset)) + 1 var width = floor(abs(rightOffset - leftOffset) + rightInset) if Int(width) % 2 != 0 { width += 1 } let height = ceil(ascent + descent) let size = NSMakeSize(width, height) let rect = CGRect(x: x, y: floor(descent - (ascent + descent)), width: size.width, height: size.height) embeddedItems.append(TextViewEmbeddedItem(range: NSMakeRange(startIndex, endIndex - startIndex), frame: rect.insetBy(dx: -2, dy: -2), item: item)) } } var lineConstrainedWidth = constrainedWidth var lineOriginY: CGFloat = 0 var lineCutoutOffset: CGFloat = 0.0 var lineAdditionalWidth: CGFloat = 0.0 var isPreformattedLine: CGFloat? = nil fontLineSpacing = lineSpacing ?? floor(fontLineHeight * 0.12) if isBigEmoji { lineOriginY += 2 } var isFirstFormattedLine: Bool = false if attributedString.length > 0, let blockQuote = blockQuote(lastLineCharacterIndex) { breakInset = CGFloat(blockQuote.space * 2 + 20) lineCutoutOffset += CGFloat(blockQuote.space * 2) lineAdditionalWidth += max(breakInset, blockQuote.minimumHeaderSize) if !isWasPreformatted { layoutSize.height += CGFloat(blockQuote.space) + fontLineSpacing layoutSize.height += blockQuote.headerInset isFirstFormattedLine = true } else { if isWasPreformatted { } else if first { layoutSize.height += CGFloat(blockQuote.space) } } isPreformattedLine = CGFloat(blockQuote.space) isWasPreformatted = true } else { // if isWasPreformatted && !first { // lineOriginY -= (2 - fontLineSpacing) // } // if let isPreformattedLine = isPreformattedLine { // fontLineSpacing += isPreformattedLine // } isWasPreformatted = false } lineOriginY += floor(layoutSize.height + fontLineHeight - fontLineSpacing * 2.0) if !first { lineOriginY += fontLineSpacing } if cutoutEnabled { if lineOriginY < cutoutMaxY && lineOriginY + fontLineHeight > cutoutMinY { lineConstrainedWidth = max(1.0, lineConstrainedWidth - cutoutWidth) lineCutoutOffset = cutoutOffset lineAdditionalWidth = cutoutWidth } } let lineCharacterCount = CTTypesetterSuggestLineBreak(typesetter, lastLineCharacterIndex, Double(lineConstrainedWidth - breakInset)) var lineRange = CFRange(location: lastLineCharacterIndex, length: lineCharacterCount) var lineHeight = fontLineHeight let lineString = attributedString.attributedSubstring(from: NSMakeRange(lastLineCharacterIndex, lineCharacterCount)) // if lineString.string.containsEmoji, !isBigEmoji { // if first { // lineHeight += floor(fontDescent) // lineOriginY += floor(fontDescent) // } // } if maximumNumberOfLines != 0 && lines.count == (Int(maximumNumberOfLines) - 1) && lineCharacterCount > 0 { if first { first = false } else { layoutSize.height += fontLineSpacing } var brokenLineRange = CFRange(location: lastLineCharacterIndex, length: lineCharacterCount) if brokenLineRange.location + brokenLineRange.length > attributedString.length { brokenLineRange.length = attributedString.length - brokenLineRange.location } let coreTextLine: CTLine let originalLine = CTTypesetterCreateLineWithOffset(typesetter, CFRange(location: lastLineCharacterIndex, length: attributedString.length - lastLineCharacterIndex), 0.0) if CTLineGetTypographicBounds(originalLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(originalLine) < Double(constrainedWidth) { coreTextLine = originalLine } else { var truncationTokenAttributes: [NSAttributedString.Key : Any] = [:] truncationTokenAttributes[NSAttributedString.Key(kCTFontAttributeName as String)] = font truncationTokenAttributes[NSAttributedString.Key(kCTForegroundColorAttributeName as String)] = truncatingColor ?? attributedString.attribute(.foregroundColor, at: min(lastLineCharacterIndex, attributedString.length - 1), effectiveRange: nil) as? NSColor ?? NSColor.black let tokenString = "\u{2026}" let truncatedTokenString = NSAttributedString(string: tokenString, attributes: truncationTokenAttributes) let truncationToken = CTLineCreateWithAttributedString(truncatedTokenString) var lineConstrainedWidth = constrainedWidth if bottomCutoutEnabled { lineConstrainedWidth -= bottomCutoutSize.width } coreTextLine = CTLineCreateTruncatedLine(originalLine, Double(lineConstrainedWidth), truncationType, truncationToken) ?? truncationToken let runs = (CTLineGetGlyphRuns(coreTextLine) as [AnyObject]) as! [CTRun] for run in runs { let runAttributes: NSDictionary = CTRunGetAttributes(run) if let _ = runAttributes["CTForegroundColorFromContext"] { brokenLineRange.length = CTRunGetStringRange(run).location - brokenLineRange.location break } } if brokenLineRange.location + brokenLineRange.length > attributedString.length { brokenLineRange.length = attributedString.length - brokenLineRange.location } isPerfectSized = false } lineRange = CTLineGetStringRange(coreTextLine) var isRTL = false let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray if glyphRuns.count != 0 { for glyphRun in glyphRuns { if CTRunGetStatus(glyphRun as! CTRun).contains(CTRunStatus.rightToLeft) { isRTL = true break } } } if isRTL { lineAdditionalWidth = 0 } let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine))) let lineFrame = CGRect(x: lineCutoutOffset, y: lineOriginY, width: lineWidth, height: lineHeight) layoutSize.height += lineHeight + fontLineSpacing layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth) if brokenLineRange.location >= 0 && brokenLineRange.length > 0 && brokenLineRange.location + brokenLineRange.length <= attributedString.length { attributedString.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in if let _ = attributes[.strikethroughStyle] { let color = attributes[.foregroundColor] as? NSColor ?? presentation.colors.text let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) var upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, min(range.location + range.length, lineRange.location + lineRange.length), nil)) if lowerX > 0 && upperX == 0 { upperX = lineWidth } let x = lowerX < upperX ? lowerX : upperX strikethroughs.append(TextViewStrikethrough(color: color, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight))) } else if let embeddedItem = attributes[TextInputAttributes.embedded] as? AnyHashable { var ascent: CGFloat = 0.0 var descent: CGFloat = 0.0 CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) } } } var penFlush = self.penFlush if penFlush == 0 { if isRTL { penFlush = 1 } } else if isRTL, penFlush == 1 { penFlush = 0 } let range = NSMakeRange(lineRange.location, lineRange.length) lines.append(TextViewLine(line: coreTextLine, frame: lineFrame, range: range, lineRange: range, penFlush: penFlush, isBlocked: isWasPreformatted, isRTL: isRTL, strikethrough: strikethroughs, embeddedItems: embeddedItems)) break } else { if lineCharacterCount > 0 { if first { first = false } else { layoutSize.height += fontLineSpacing } let coreTextLine = CTTypesetterCreateLineWithOffset(typesetter, CFRangeMake(lastLineCharacterIndex, lineCharacterCount), 100.0) let lineWidth = ceil(CGFloat(CTLineGetTypographicBounds(coreTextLine, nil, nil, nil) - CTLineGetTrailingWhitespaceWidth(coreTextLine))) let lineFrame = CGRect(x: lineCutoutOffset, y: lineOriginY, width: lineWidth, height: lineHeight) layoutSize.height += lineHeight layoutSize.width = max(layoutSize.width, lineWidth + lineAdditionalWidth) if let blockQuote = lineString.containsAttribute(attributeName: TextInputAttributes.quote) as? TextViewBlockQuoteData { let preformattedSpace = CGFloat(blockQuote.space) * 2 var frame = NSMakeRect(0, lineFrame.minY - lineFrame.height, lineWidth + max(preformattedSpace + 20, lineAdditionalWidth), lineFrame.height + preformattedSpace) if isFirstFormattedLine { frame.origin.y -= blockQuote.headerInset frame.size.height += blockQuote.headerInset } blockQuotes.append(.init(frame: frame, range: NSMakeRange(lineRange.location, lineRange.length), colors: blockQuote.colors, isCode: blockQuote.isCode, header: blockQuote.header)) } attributedString.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in if let _ = attributes[.strikethroughStyle] { let color = attributes[.foregroundColor] as? NSColor ?? presentation.colors.text let lowerX = floor(CTLineGetOffsetForStringIndex(coreTextLine, range.location, nil)) let upperX = ceil(CTLineGetOffsetForStringIndex(coreTextLine, range.location + range.length, nil)) let x = lowerX < upperX ? lowerX : upperX strikethroughs.append(TextViewStrikethrough(color: color, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: fontLineHeight))) } else if let embeddedItem = attributes[TextInputAttributes.embedded] as? AnyHashable { var ascent: CGFloat = 0.0 var descent: CGFloat = 0.0 CTLineGetTypographicBounds(coreTextLine, &ascent, &descent, nil) addEmbeddedItem(item: embeddedItem, line: coreTextLine, ascent: ascent, descent: descent, startIndex: range.location, endIndex: range.location + range.length) } } var isRTL = false let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray if glyphRuns.count != 0 { let run = glyphRuns[0] as! CTRun if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { isRTL = true } } var penFlush = self.penFlush if penFlush == 0 { if isRTL { penFlush = 1 } } else if isRTL, penFlush == 1 { penFlush = 0 } let range = NSMakeRange(lineRange.location, lineRange.length) lines.append(TextViewLine(line: coreTextLine, frame: lineFrame, range: range, lineRange: range, penFlush: penFlush, isBlocked: isWasPreformatted, isRTL: isRTL, strikethrough: strikethroughs, embeddedItems: embeddedItems)) lastLineCharacterIndex += lineCharacterCount } else { if !lines.isEmpty { layoutSize.height += fontLineSpacing } break } } if let isPreformattedLine = isPreformattedLine { if lastLineCharacterIndex == attributedString.length || blockQuote(lastLineCharacterIndex) == nil { layoutSize.height += isPreformattedLine + isPreformattedLine / 2 } } } self.blockQuotes = combineIntersectingRectangles(self.blockQuotes) // if mayBlocked { // // self.blockImage = generateRectsImage(color: presentation.colors.grayBackground, rects: monospacedRects, inset: 0, outerRadius: .cornerRadius, innerRadius: .cornerRadius) // } var embeddedItems: [EmbeddedItem] = [] for line in lines { for embeddedItem in line.embeddedItems { let penOffset = floor(CGFloat(CTLineGetPenOffsetForFlush(line.line, line.penFlush, Double(layoutSize.width)))) embeddedItems.append(EmbeddedItem(range: embeddedItem.range, rect: embeddedItem.frame.offsetBy(dx: line.frame.minX, dy: line.frame.minY).offsetBy(dx: penOffset, dy: 0), value: embeddedItem.item)) } } self.embeddedItems = embeddedItems if saveRTL { layoutSize.width = max(layoutSize.width, constrainedWidth) } self.layoutSize = layoutSize } public func rects(_ fromRange: NSRange) -> [(CGRect, TextViewLine)] { var rects:[(CGRect, TextViewLine)] = [] for i in 0 ..< lines.count { let line = lines[i] if let intersection = fromRange.intersection(line.range) { let range = intersection.intersection(selectedRange.range) var ranges:[(NSRange, TextViewLine)] = [] if let range = range { ranges.append((NSMakeRange(fromRange.lowerBound, range.lowerBound - fromRange.lowerBound), line)) ranges.append((NSMakeRange(fromRange.lowerBound, range.lowerBound - fromRange.lowerBound), line)) } else { ranges.append((intersection, line)) } for range in ranges { let startOffset = CTLineGetOffsetForStringIndex(line.line, range.0.lowerBound, nil); let endOffset = CTLineGetOffsetForStringIndex(line.line, range.0.upperBound, nil); var ascent:CGFloat = 0 var descent:CGFloat = 0 var leading:CGFloat = 0 _ = CGFloat(CTLineGetTypographicBounds(line.line, &ascent, &descent, &leading)); var rect:NSRect = line.frame rect.size.width = abs(endOffset - startOffset) rect.origin.x = min(startOffset, endOffset) + line.frame.origin.x rect.origin.y = rect.minY - rect.height + 2 rect.size.height += ceil(descent - leading) rects.append((rect, line)) } } } return rects } public func generateBlock(for range: NSRange, backgroundColor: NSColor) -> (CGPoint, CGImage?) { let rectsAndLines = rects(range) var rects = rectsAndLines.map { $0.0 } let lines = rectsAndLines.map { $0.1 } if !rects.isEmpty { let sortedIndices = (0 ..< rects.count).sorted(by: { rects[$0].width > rects[$1].width }) for i in 0 ..< sortedIndices.count { let index = sortedIndices[i] for j in -1 ... 1 { if j != 0 && index + j >= 0 && index + j < sortedIndices.count { if abs(rects[index + j].width - rects[index].width) < 10 { rects[index + j].size.width = max(rects[index + j].width, rects[index].width) } } } } for i in 0 ..< rects.count { let height = rects[i].size.height rects[i] = rects[i].insetBy(dx: 0, dy: floor((rects[i].height - height) / 2.0)) rects[i].size.height = height if lines[i].penFlush == 1.0 { rects[i].origin.x = layoutSize.width - rects[i].width } else if lines[i].penFlush == 0.5 { rects[i].origin.x = floor((layoutSize.width - rects[i].width) / 2.0) } } return generateRectsImage(color: backgroundColor, rects: rects, inset: 0, outerRadius: 4, innerRadius: 4, useModernPathCalculation: false) // var image = generateRectsImage(color: backgroundColor, rects: rects, inset: 0, outerRadius: rects[0].height / 2 , innerRadius: 10) // image.0 = rects[0].origin // // return image } else { return (.zero, nil) } } public func generateBlock(backgroundColor: NSColor) -> (CGPoint, CGImage?) { let lines = self.lines.filter { self.attributedString.attributedSubstring(from: $0.range).string != "\n" } var rects = lines.map({ $0.frame }) if !rects.isEmpty { let sortedIndices = (0 ..< rects.count).sorted(by: { rects[$0].width > rects[$1].width }) for i in 0 ..< sortedIndices.count { let index = sortedIndices[i] for j in -1 ... 1 { if j != 0 && index + j >= 0 && index + j < sortedIndices.count { if abs(rects[index + j].width - rects[index].width) < 10 { rects[index + j].size.width = max(rects[index + j].width, rects[index].width) } } } } for i in 0 ..< rects.count { let height = rects[i].size.height + 5 rects[i] = rects[i].insetBy(dx: 0, dy: floor((rects[i].height - height) / 2.0)) rects[i].size.height = height if lines[i].penFlush == 1.0 { rects[i].origin.x = layoutSize.width - rects[i].width - 5 rects[i].size.width += 10 } else if lines[i].penFlush == 0.5 { rects[i].origin.x = floor((layoutSize.width - rects[i].width) / 2.0) rects[i].size.width += 20 } else { rects[i].size.width += 10 rects[i].origin.x -= 5 } } var image = generateRectsImage(color: backgroundColor, rects: rects, inset: 0, outerRadius: lines.count == 1 ? rects[0].height / 2 : 10, innerRadius: .cornerRadius) image.0 = NSMakePoint(0, 0) return image } else { return (.zero, nil) } } public func generateAutoBlock(backgroundColor: NSColor, minusHeight: CGFloat = 0, yInset: CGFloat = 0) { var rects = self.lines.map({ $0.frame }) if !rects.isEmpty { let sortedIndices = (0 ..< rects.count).sorted(by: { rects[$0].width > rects[$1].width }) for i in 0 ..< sortedIndices.count { let index = sortedIndices[i] for j in -1 ... 1 { if j != 0 && index + j >= 0 && index + j < sortedIndices.count { let dif = minusHeight != 0 ? 10 : 40.0 if abs(rects[index + j].width - rects[index].width) < dif { rects[index + j].size.width = max(rects[index + j].width, rects[index].width) } } } } for i in 0 ..< rects.count { let height = rects[i].size.height + 5 rects[i] = rects[i].insetBy(dx: 0, dy: floor((rects[i].height - height) / 2.0)) rects[i].size.height = height if self.penFlush == 0.5 { rects[i].origin.x = floor((layoutSize.width - rects[i].width) / 2.0) rects[i].size.width += 14 } else { rects[i].size.width += 10 rects[i].origin.x -= 5 } } self.blockImage = generateRectsImage(color: backgroundColor, rects: rects, inset: 0, outerRadius: lines.count == 1 ? rects[0].height / 2 : 10, innerRadius: .cornerRadius) self.blockImage.0 = NSMakePoint(0, 0) var offset: NSPoint = NSPoint(x: 0, y: 0) if self.penFlush == 0.5 { offset.y = 2 layoutSize.width += 20 } else { layoutSize.width += 10 offset.x = 5 } for i in 0 ..< lines.count { let line = lines[i] lines[i] = TextViewLine(line: line.line, frame: line.frame.offsetBy(dx: offset.x, dy: offset.y), range: line.range, lineRange: line.range, penFlush: self.penFlush, strikethrough: line.strikethrough, embeddedItems: line.embeddedItems) } layoutSize.height = rects.last!.maxY - minusHeight } else { self.blockImage = (.zero, nil) } } public func selectNextChar() { var range = selectedRange.range switch selectedRange.cursorAlignment { case let .min(cursorAlignment), let .max(cursorAlignment): if range.min >= cursorAlignment { range.length += 1 } else { range.location += 1 if range.length > 1 { range.length -= 1 } } } let location = min(max(0, range.location), attributedString.length) let length = max(min(range.length, attributedString.length - location), 0) selectedRange.range = NSMakeRange(location, length) } public func selectPrevChar() { var range = selectedRange.range switch selectedRange.cursorAlignment { case let .min(cursorAlignment), let .max(cursorAlignment): if range.location >= cursorAlignment { if range.length > 1 { range.length -= 1 } else { range.location -= 1 } } else { if range.location > 0 { range.location -= 1 range.length += 1 } } } let location = min(max(0, range.location), attributedString.length) let length = max(min(range.length, attributedString.length - location), 0) selectedRange.range = NSMakeRange(location, length) } public func fitToLines(_ count: Int) { if lines.count > count { let lines = Array(lines.prefix(count)) self.layoutSize = NSMakeSize(self.layoutSize.width, lines[lines.count - 1].frame.minY + 2) } } public func measure(width: CGFloat = 0, isBigEmoji: Bool = false, lineSpacing: CGFloat? = nil, saveRTL: Bool = false) -> Void { if width != 0 { constrainedWidth = width } toolTipRects.removeAll() calculateLayout(isBigEmoji: isBigEmoji, lineSpacing: lineSpacing, saveRTL: saveRTL) strokeRects.removeAll() attributedString.enumerateAttribute(NSAttributedString.Key.link, in: attributedString.range, options: NSAttributedString.EnumerationOptions(rawValue: 0), using: { value, range, stop in if let value = value { if interactions.isDomainLink(value, attributedString.attributedSubstring(from: range).string) && strokeLinks { for line in lines { let lineRange = NSIntersectionRange(range, line.range) if lineRange.length != 0 { var leftOffset: CGFloat = 0.0 if lineRange.location != line.range.location { leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) } let rightOffset: CGFloat = ceil(CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, nil)) let color: NSColor = attributedString.attribute(NSAttributedString.Key.foregroundColor, at: range.location, effectiveRange: nil) as? NSColor ?? presentation.colors.link let rect = NSMakeRect(line.frame.minX + leftOffset + (line.isRTL ? layoutSize.width - line.frame.width : 0), line.frame.minY + 1, rightOffset - leftOffset, 1.0) strokeRects.append((rect, color)) if !disableTooltips, interactions.resolveLink(value) != attributedString.string.nsstring.substring(with: range) { var leftOffset: CGFloat = 0.0 if lineRange.location != line.range.location { leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) } let rightOffset: CGFloat = ceil(CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, nil)) toolTipRects.append(NSMakeRect(rect.minX, line.frame.minY - line.frame.height, rightOffset - leftOffset, line.frame.height)) } } } } if !disableTooltips, interactions.resolveLink(value) != attributedString.string.nsstring.substring(with: range) { for line in lines { let lineRange = NSIntersectionRange(range, line.range) if lineRange.length != 0 { var leftOffset: CGFloat = 0.0 if lineRange.location != line.range.location { leftOffset = floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) } let rightOffset: CGFloat = ceil(CTLineGetOffsetForStringIndex(line.line, lineRange.location + lineRange.length, nil)) toolTipRects.append(NSMakeRect(line.frame.minX + leftOffset, line.frame.minY - line.frame.height, rightOffset - leftOffset, line.frame.height)) } } } } }) hexColorsRect.removeAll() attributedString.enumerateAttribute(NSAttributedString.Key.hexColorMark, in: attributedString.range, options: NSAttributedString.EnumerationOptions(rawValue: 0), using: { value, range, stop in if let color = value as? NSColor, let size = attributedString.attribute(.hexColorMarkDimensions, at: range.location, effectiveRange: nil) as? NSSize { for line in lines { let lineRange = NSIntersectionRange(range, line.range) if lineRange.length != 0 { var leftOffset: CGFloat = 0 if lineRange.location != line.range.location { leftOffset += floor(CTLineGetOffsetForStringIndex(line.line, lineRange.location, nil)) } let rect = NSMakeRect(line.frame.minX + leftOffset + 10, line.frame.minY - (size.height - 7) + 2, size.width - 8, size.height - 8) hexColorsRect.append((rect, color, attributedString.attributedSubstring(from: range).string)) } } } }) } public func clearSelect() { self.selectedRange.range = NSMakeRange(NSNotFound, 0) } private func map(_ index: Int, byWord: Bool, forward: Bool) -> Int { if byWord { let range = self.attributedString.doubleClick(at: index) if forward { return range.max } else { return range.min } } else { return index } } public func selectedRange(startPoint:NSPoint, currentPoint:NSPoint, byWord: Bool = false) -> NSRange { var selectedRange:NSRange = NSMakeRange(NSNotFound, 0) if (currentPoint.x != -1 && currentPoint.y != -1 && !lines.isEmpty && startPoint.x != -1 && startPoint.y != -1) { let startSelectLineIndex = findIndex(location: startPoint) let currentSelectLineIndex = findIndex(location: currentPoint) let dif = abs(startSelectLineIndex - currentSelectLineIndex) let isReversed = currentSelectLineIndex < startSelectLineIndex var i = startSelectLineIndex while isReversed ? i >= currentSelectLineIndex : i <= currentSelectLineIndex { let line = lines[i].line let penOffset = CGFloat( CTLineGetPenOffsetForFlush(lines[i].line, lines[i].penFlush, Double(layoutSize.width))) let lineRange = CTLineGetStringRange(line) let sp = startPoint.offsetBy(dx: -penOffset - lines[i].frame.minX, dy: 0) let cp = currentPoint.offsetBy(dx: -penOffset - lines[i].frame.minX, dy: 0) var startIndex: CFIndex = CTLineGetStringIndexForPosition(line, sp) var endIndex: CFIndex = CTLineGetStringIndexForPosition(line, cp) if dif > 0 { if i != currentSelectLineIndex { endIndex = (lineRange.length + lineRange.location) } if i != startSelectLineIndex { startIndex = lineRange.location } if isReversed { if i == startSelectLineIndex { endIndex = startIndex startIndex = lineRange.location } if i == currentSelectLineIndex { startIndex = endIndex endIndex = (lineRange.length + lineRange.location) } } } if startIndex > endIndex { startIndex = endIndex + startIndex endIndex = startIndex - endIndex startIndex = startIndex - endIndex } if abs(Int(startIndex) - Int(endIndex)) > 0 && (selectedRange.location == NSNotFound || selectedRange.location > startIndex) { selectedRange.location = startIndex } selectedRange.length += (endIndex - startIndex) i += isReversed ? -1 : 1 } } if selectedRange.location != NSNotFound, selectedRange.length > 0 { let start = selectedRange.min let end = selectedRange.max selectedRange.location = map(start, byWord: byWord, forward: false) selectedRange.length = map(end, byWord: byWord, forward: true) - selectedRange.location } return selectedRange } public func findIndex(location:NSPoint) -> Int { if location.y == .greatestFiniteMagnitude { return lines.count - 1 } else if location.y == 0 { return 0 } let rect = findClosestRect(rectangles: self.lines.map { $0.frame }, point: location) return lines.firstIndex(where: { $0.frame == rect }) ?? 0 //var previous:NSRect = lines[0].frame // var index: Int = 0 // for idx in 0 ..< lines.count { // if isCurrentLine(pos: location, index: idx) { // return idx // } // } // return location.y <= layoutSize.height ? 0 : (lines.count - 1) } public func inSelectedRange(_ location:NSPoint) -> Bool { let index = findCharacterIndex(at: location) return selectedRange.range.indexIn(index) } public func isCurrentLine(pos:NSPoint, index:Int) -> Bool { let line = lines[index] var rect = line.frame var ascent:CGFloat = 0 var descent:CGFloat = 0 var leading:CGFloat = 0 CTLineGetTypographicBounds(line.line, &ascent, &descent, &leading) rect.origin.y = rect.minY - rect.height + ceil(descent - leading) rect.size.height += ceil(descent - leading) if line.isBlocked { rect.size.height += 2 } return (pos.y > rect.minY) && pos.y < rect.maxY } fileprivate func color(at point: NSPoint) -> (NSColor, String)? { for value in self.hexColorsRect { if NSPointInRect(point, value.0) { return (value.1, value.2) } } return nil } func spoiler(at point: NSPoint) -> Spoiler? { let rects = spoilerRects() for rect in rects { if rect.0.contains(point) { if !rect.1.isRevealed { return rect.1 } } } return nil } func spoilerRects(_ checkRevealed: Bool = true) -> [(CGRect, Spoiler)] { var rects:[(CGRect, Spoiler)] = [] for i in 0 ..< lines.count { let line = lines[i] for spoiler in spoilers.filter({ !$0.isRevealed || !checkRevealed }) { if let spoilerRange = spoiler.range.intersection(line.range) { let range = spoilerRange.intersection(selectedRange.range) var ranges:[(NSRange, NSColor)] = [] if let range = range { ranges.append((NSMakeRange(spoiler.range.lowerBound, range.lowerBound - spoiler.range.lowerBound), spoiler.color)) ranges.append((NSMakeRange(spoiler.range.upperBound, range.upperBound - spoiler.range.upperBound), spoiler.color)) } else { ranges.append((spoilerRange, spoiler.color)) } for range in ranges { let startOffset = CTLineGetOffsetForStringIndex(line.line, range.0.lowerBound, nil); let endOffset = CTLineGetOffsetForStringIndex(line.line, range.0.upperBound, nil); var ascent:CGFloat = 0 var descent:CGFloat = 0 var leading:CGFloat = 0 _ = CGFloat(CTLineGetTypographicBounds(line.line, &ascent, &descent, &leading)); var rect:NSRect = line.frame rect.size.width = abs(endOffset - startOffset) rect.origin.x = min(startOffset, endOffset) + line.frame.origin.x rect.origin.y = rect.minY - rect.height + 2 rect.size.height += ceil(descent - leading) if line.isRTL { rect.origin.x += layoutSize.width - line.frame.width } rects.append((rect, spoiler)) } } } } return rects } public func link(at point:NSPoint) -> (Any, LinkType, NSRange, NSRect)? { let index = findIndex(location: point) guard index != -1, !lines.isEmpty else { return nil } let line = lines[index] var ascent:CGFloat = 0 var descent:CGFloat = 0 var leading:CGFloat = 0 let width:CGFloat = CGFloat(CTLineGetTypographicBounds(line.line, &ascent, &descent, &leading)); var point = point //point.x -= floorToScreenPixels(System.backingScale, (frame.width - line.frame.width) / 2) // var penOffset = CGFloat( CTLineGetPenOffsetForFlush(line.line, line.penFlush, Double(frame.width))) + line.frame.minX // if layout.penFlush == 0.5, line.penFlush != 0.5 { // penOffset = startPosition.x // } else if layout.penFlush == 0.0 { // penOffset = startPosition.x // } point.x -= ((layoutSize.width - line.frame.width) * line.penFlush) if width > point.x, point.x >= 0 { var pos = CTLineGetStringIndexForPosition(line.line, point); pos = min(max(0,pos),attributedString.length - 1) var range:NSRange = NSMakeRange(NSNotFound, 0) let attrs = attributedString.attributes(at: pos, effectiveRange: &range) let link:Any? = attrs[NSAttributedString.Key.link] if let link = link { let startOffset = CTLineGetOffsetForStringIndex(line.line, range.location, nil); let endOffset = CTLineGetOffsetForStringIndex(line.line, range.location + range.length, nil); return (link, interactions.makeLinkType((link, attributedString.attributedSubstring(from: range).string)), range, NSMakeRect(startOffset, line.frame.minY, endOffset - startOffset, ceil(ascent + ceil(descent) + leading))) } } return nil } public func block(at point:NSPoint) -> NSRange? { let index = findIndex(location: point) guard index != -1, !lines.isEmpty else { return nil } let line = lines[index] var ascent:CGFloat = 0 var descent:CGFloat = 0 var leading:CGFloat = 0 let width:CGFloat = CGFloat(CTLineGetTypographicBounds(line.line, &ascent, &descent, &leading)); var point = point //point.x -= floorToScreenPixels(System.backingScale, (frame.width - line.frame.width) / 2) // var penOffset = CGFloat( CTLineGetPenOffsetForFlush(line.line, line.penFlush, Double(frame.width))) + line.frame.minX // if layout.penFlush == 0.5, line.penFlush != 0.5 { // penOffset = startPosition.x // } else if layout.penFlush == 0.0 { // penOffset = startPosition.x // } point.x -= ((layoutSize.width - line.frame.width) * line.penFlush) if width > point.x, point.x >= 0 { var pos = CTLineGetStringIndexForPosition(line.line, point); pos = min(max(0,pos),attributedString.length - 1) var range:NSRange = NSMakeRange(NSNotFound, 0) let attrs = attributedString.attributes(at: pos, effectiveRange: &range) if let _ = attrs[TextInputAttributes.code] { return range } } return nil } func findCharacterIndex(at point:NSPoint) -> Int { let index = findIndex(location: point) guard index != -1 else { return index } let line = lines[index] let width:CGFloat = CGFloat(CTLineGetTypographicBounds(line.line, nil, nil, nil)); if width > point.x { var charIndex = Int(CTLineGetStringIndexForPosition(line.line, point)) let ctRange = CTLineGetStringRange(line.line) charIndex = (charIndex - ctRange.location) + line.range.location return charIndex == attributedString.length ? charIndex - 1 : charIndex } return -1 } public func offset(for index: Int) -> CGFloat? { let line = self.lines.first(where: { $0.range.indexIn(index) }) if let line = line { return CTLineGetOffsetForStringIndex(line.line, index, nil) } return nil } public func selectAll(at point:NSPoint) -> Void { let startIndex = findCharacterIndex(at: point) if startIndex == -1 { return } var blockRange: NSRange = NSMakeRange(NSNotFound, 0) var firstIndex: Int = startIndex var lastIndex: Int = startIndex var firstFound: Bool = false var lastFound: Bool = false let block = attributedString.attribute(TextInputAttributes.quote, at: firstIndex, effectiveRange: &blockRange) as? TextViewBlockQuoteData attributedString.enumerateAttribute(TextInputAttributes.quote, in: attributedString.range, using: { value, range, _ in if value as? TextViewBlockQuoteData === block { blockRange.location = min(range.location, blockRange.location) blockRange.length += range.length } }) if blockRange.location != NSNotFound, block != nil { firstIndex = blockRange.min - 1 lastIndex = blockRange.max + 1 firstFound = true lastFound = true } while (firstIndex > 0 && !firstFound) || (lastIndex < self.attributedString.length - 1 && !lastFound) { let firstSymbol = self.attributedString.string.nsstring.substring(with: NSMakeRange(firstIndex, 1)) let lastSymbol = self.attributedString.string.nsstring.substring(with: NSMakeRange(lastIndex, 1)) firstFound = firstSymbol == "\n" lastFound = lastSymbol == "\n" if firstIndex > 0, !firstFound { firstIndex -= 1 } if lastIndex < self.attributedString.length - 1, !lastFound { lastIndex += 1 } } if lastFound { lastIndex = max(min(lastIndex, self.attributedString.length - 1) - 1, 0) } if firstFound { firstIndex = min(firstIndex + 1, self.attributedString.length - 1) } self.selectedRange = TextSelectedRange(range: NSMakeRange(firstIndex, (lastIndex + 1) - firstIndex), color: selectText, def: true) } public func selectWord(at point:NSPoint) -> Void { if selectWholeText { self.selectedRange = TextSelectedRange(range: attributedString.range, color: selectText, def: true) return } let startIndex = findCharacterIndex(at: point) if startIndex == -1 { return } var prev = startIndex var next = startIndex var range = NSMakeRange(startIndex, 1) let char:NSString = attributedString.string.nsstring.substring(with: range) as NSString var effectiveRange:NSRange = NSMakeRange(NSNotFound, 0) let check = attributedString.attribute(NSAttributedString.Key.link, at: range.location, effectiveRange: &effectiveRange) if check != nil && effectiveRange.location != NSNotFound { self.selectedRange = TextSelectedRange(range: effectiveRange, color: selectText, def: true) return } if char == "" { self.selectedRange = TextSelectedRange(color: selectText) return } let string:NSString = attributedString.string.nsstring let tidyChar = char.trimmingCharacters(in: NSCharacterSet.alphanumerics) let valid:Bool = tidyChar == "" || tidyChar == "_" || tidyChar == "\u{FFFD}" var inBlockRange: NSRange = NSMakeRange(0, 0) let inBlock = attributedString.attribute(TextInputAttributes.quote, at: startIndex, effectiveRange: &inBlockRange) as? TextViewBlockQuoteData attributedString.enumerateAttribute(TextInputAttributes.quote, in: attributedString.range, using: { value, range, _ in if value as? TextViewBlockQuoteData === inBlock { inBlockRange.location = min(range.location, inBlockRange.location) inBlockRange.length += range.length } }) if let _ = inBlock { range = inBlockRange } else { while valid { let prevChar = string.substring(with: NSMakeRange(prev, 1)) let nextChar = string.substring(with: NSMakeRange(next, 1)) let prevBlock = attributedString.attribute(.init("Attribute__Blockquote"), at: prev, effectiveRange: nil) let nextBlock = attributedString.attribute(.init("Attribute__Blockquote"), at: next, effectiveRange: nil) let tidyPrev = prevChar.trimmingCharacters(in: NSCharacterSet.alphanumerics) let tidyNext = nextChar.trimmingCharacters(in: NSCharacterSet.alphanumerics) var prevValid:Bool = (tidyPrev == "" || tidyPrev == "_" || tidyPrev == "\u{FFFD}") && prevBlock == nil var nextValid:Bool = (tidyNext == "" || tidyNext == "_" || tidyNext == "\u{FFFD}") && nextBlock == nil if (prevValid && prev > 0) { prev -= 1 } if(nextValid && next < string.length - 1) { next += 1 } range.location = prevValid ? prev : prev + 1; range.length = next - range.location; if prev == 0 { prevValid = false } if(next == string.length - 1) { nextValid = false let nextChar = string.substring(with: NSMakeRange(next, 1)) let nextTidy = nextChar.trimmingCharacters(in: NSCharacterSet.alphanumerics) if nextTidy == "" || nextTidy == "_" || nextTidy == "\u{FFFD}" { range.length += 1 } } if !prevValid && !nextValid { break } if prev == 0 && !nextValid { break } } } self.selectedRange = TextSelectedRange(range: NSMakeRange(max(range.location, 0), min(max(range.length, 0), string.length)), color: selectText, def: true) } } public func ==(lhs:TextViewLayout, rhs:TextViewLayout) -> Bool { return lhs.constrainedWidth == rhs.constrainedWidth && lhs.attributedString.isEqual(to: rhs.attributedString) && lhs.selectedRange == rhs.selectedRange && lhs.maximumNumberOfLines == rhs.maximumNumberOfLines && lhs.cutout == rhs.cutout && lhs.truncationType == rhs.truncationType && lhs.constrainedWidth == rhs.constrainedWidth } public enum CursorSelectAlignment { case min(Int) case max(Int) } public struct TextSelectedRange: Equatable { public var range:NSRange = NSMakeRange(NSNotFound, 0) public var color:NSColor = presentation.colors.selectText public var def:Bool = true public init(range: NSRange = NSMakeRange(NSNotFound, 0), color: NSColor = presentation.colors.selectText, def: Bool = true, cursorAlignment: CursorSelectAlignment = .min(0)) { self.range = range self.color = color self.def = def self.cursorAlignment = cursorAlignment } public var cursorAlignment: CursorSelectAlignment = .min(0) public var hasSelectText:Bool { return range.location != NSNotFound } } public func ==(lhs:TextSelectedRange, rhs:TextSelectedRange) -> Bool { return lhs.def == rhs.def && lhs.range.location == rhs.range.location && lhs.range.length == rhs.range.length && lhs.color.hexString == rhs.color.hexString } //private extension TextView : NSMenuDelegate { // //} private final class TextDrawLayer : SimpleLayer { var drawer:((CGContext)->Void)? = nil override func draw(in ctx: CGContext) { drawer?(ctx) } } public class TextView: Control, NSViewToolTipOwner, ViewDisplayDelegate { public func view(_ view: NSView, stringForToolTip tag: NSView.ToolTipTag, point: NSPoint, userData data: UnsafeMutableRawPointer?) -> String { guard let layout = self.textLayout else { return "" } if let link = layout.link(at: point), let resolved = layout.interactions.resolveLink(link.0)?.removingPercentEncoding { return resolved.prefixWithDots(70) } return "" } private class EmbeddedContainer : View { required init(frame frameRect: NSRect) { super.init(frame: frameRect) isEventLess = true } required public init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } private var inkViews: [InvisibleInkDustView] = [] public let embeddedContainer = SimpleLayer() private var blockHeaderRects: [(NSRect, NSRange)] = [] private var shimmerEffect: ShimmerView? private var shimmerMask: SimpleLayer? public var selectionWasCleared: Bool = false private var clearExceptRevealed: Bool = false private var inAnimation: Bool = false { didSet { setNeedsDisplayLayer() } } private var visualEffect: VisualEffect? = nil private var textView: View? = nil private let drawLayer: TextDrawLayer = TextDrawLayer() private var blockMask: SimpleLayer? var hasBackground: Bool { return blurBackground != nil } public var blurBackground: NSColor? = nil { didSet { updateBackgroundBlur() if hasBackground { self.backgroundColor = .clear } } } public var canDrawBlocks: Bool = true { didSet { drawLayer.needsDisplay() } } private let menuDisposable = MetaDisposable() private(set) public var textLayout:TextViewLayout? private var beginSelect:NSPoint = NSZeroPoint private var endSelect:NSPoint = NSZeroPoint public var canBeResponder:Bool = true public var isSelectable:Bool = true { didSet { if oldValue != isSelectable { self.setNeedsDisplayLayer() } } } public var onlyTextIsInteractive: Bool = false public override init() { super.init(); initialize() // wantsLayer = false // self.layer?.delegate = nil } public override var isFlipped: Bool { return true } private func initialize() { layer?.disableActions() self.style = ControlStyle(backgroundColor: .clear) self.layer?.addSublayer(drawLayer) self.drawLayer.drawer = { [weak self] ctx in guard let `self` = self else { return } self.draw(self.drawLayer, in: ctx) } embeddedContainer.masksToBounds = false self.layer?.addSublayer(embeddedContainer) self.layer?.masksToBounds = false } public required init(frame frameRect: NSRect) { super.init(frame:frameRect) initialize() } private var _disableBackgroundDrawing: Bool = false public var disableBackgroundDrawing: Bool { set { _disableBackgroundDrawing = newValue } get { return _disableBackgroundDrawing || hasBackground } } public override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) } public var drawingLayer: CALayer? { return drawLayer } public func set(mask: CALayer?) { self.drawLayer.mask = mask if let mask { let copy = SimpleLayer() copy.contentsGravity = mask.contentsGravity copy.frame = mask.frame copy.contents = mask.contents self.embeddedContainer.mask = copy } else { self.embeddedContainer.mask = nil } } public override func draw(_ layer: CALayer, in ctx: CGContext) { //backgroundColor = .random // super.draw(layer, in: ctx) // if hasBackground, layer != textView?.layer { // return // } var blockHeaderRects: [(NSRect, NSRange)] = [] if let layout = textLayout, drawingLayer == layer { ctx.setAllowsFontSubpixelPositioning(true) ctx.setShouldSubpixelPositionFonts(true) if !System.supportsTransparentFontDrawing { ctx.setAllowsAntialiasing(true) ctx.setAllowsFontSmoothing(backingScaleFactor == 1.0) ctx.setShouldSmoothFonts(backingScaleFactor == 1.0) if backingScaleFactor == 1.0 && !disableBackgroundDrawing { ctx.setFillColor(backgroundColor.cgColor) for line in layout.lines { ctx.fill(NSMakeRect(0, line.frame.minY - line.frame.height - 2, line.frame.width, line.frame.height + 6)) } } } else { ctx.setAllowsAntialiasing(true) ctx.setShouldAntialias(true) ctx.setAllowsFontSmoothing(backingScaleFactor == 1.0) ctx.setShouldSmoothFonts(backingScaleFactor == 1.0) } if clearExceptRevealed { let path = CGMutablePath() for spoiler in layout.spoilerRects(false) { path.addRect(spoiler.0) } ctx.addPath(path) ctx.clip() } // if !System.supportsTransparentFontDrawing { // ctx.setAllowsAntialiasing(true) // // ctx.setAllowsFontSmoothing(backingScaleFactor == 1.0) // ctx.setShouldSmoothFonts(backingScaleFactor == 1.0) // // if backingScaleFactor == 1.0 && !disableBackgroundDrawing { // ctx.setFillColor(backgroundColor.cgColor) // for line in layout.lines { // ctx.fill(NSMakeRect(0, line.frame.minY - line.frame.height - 2, line.frame.width, line.frame.height + 6)) // } // } // } // if let image = layout.blockImage.1, !hasBackground { ctx.draw(image, in: NSMakeRect(layout.blockImage.0.x, layout.blockImage.0.y, image.backingSize.width, image.backingSize.height)) } var ranges:[(TextSelectedRange, Bool)] = [(layout.selectedRange, true)] ranges += layout.additionalSelections.map { ($0, false) } for range in ranges { if range.0.range.location != NSNotFound && (range.1 && isSelectable || !range.1) { var lessRange = range.0.range let lines:[TextViewLine] = layout.lines let beginIndex:Int = 0 let endIndex:Int = layout.lines.count - 1 let isReversed = endIndex < beginIndex var i:Int = beginIndex while isReversed ? i >= endIndex : i <= endIndex { let line = lines[i].line let penOffset = CGFloat(CTLineGetPenOffsetForFlush(lines[i].line, lines[i].penFlush, Double(frame.width))) var rect:NSRect = lines[i].frame let lineRange = lines[i].lineRange var beginLineIndex:CFIndex = 0 var endLineIndex:CFIndex = 0 if let _ = lineRange.intersection(lessRange){ beginLineIndex = lessRange.location let max = lineRange.length + lineRange.location let maxSelect = max - beginLineIndex let selectLength = min(maxSelect,lessRange.length) lessRange.length-=selectLength lessRange.location+=selectLength endLineIndex = min(beginLineIndex + selectLength, lineRange.max) var ascent:CGFloat = 0 var descent:CGFloat = 0 var leading:CGFloat = 0 var width:CGFloat = CGFloat(CTLineGetTypographicBounds(line, &ascent, &descent, &leading)); let startOffset = CTLineGetOffsetForStringIndex(line, beginLineIndex, nil); var endOffset = CTLineGetOffsetForStringIndex(line, endLineIndex, nil); if beginLineIndex < endLineIndex { var index = endLineIndex - 1 while endOffset == 0 && index > 0 { endOffset = CTLineGetOffsetForStringIndex(line, index, nil); index -= 1 } } width = endOffset - startOffset; if beginLineIndex == -1 { beginLineIndex = 0 } else if beginLineIndex >= layout.attributedString.length { beginLineIndex = layout.attributedString.length - 1 } rect.size.width = width rect.origin.x = penOffset + startOffset rect.origin.y = rect.minY - rect.height + rect.height * 0.12 let insideBlock = layout.blockQuotes.first(where: { block in if block.frame.contains(rect.origin) { return true } else { return false } }) if let _ = insideBlock { rect.origin.x += 4 * 2 } if let _ = insideBlock, lines[i].isRTL { rect.origin.x -= 20 + 4 * 2 } rect.size.height += ceil(descent - leading) let color:NSColor = window?.isKeyWindow == true || !range.1 ? range.0.color : NSColor.lightGray ctx.setFillColor(color.cgColor) ctx.fill(rect) } i += isReversed ? -1 : 1 } } } let startPosition = focus(layout.layoutSize).origin ctx.textMatrix = CGAffineTransform(scaleX: 1.0, y: -1.0) if canDrawBlocks { for blockQuote in layout.blockQuotes { let radius: CGFloat = 3.0 let lineWidth: CGFloat = 3.0 let blockFrame = blockQuote.frame let blockColor = blockQuote.isCode ? blockQuote.colors.tertiary ?? blockQuote.colors.main : blockQuote.colors.main let tintColor = blockQuote.colors.main let secondaryTintColor = blockQuote.colors.secondary let tertiaryTintColor = blockQuote.colors.tertiary ctx.setFillColor(blockColor.withAlphaComponent(blockQuote.isCode ? 0.25 : 0.1).cgColor) ctx.addPath(CGPath(roundedRect: blockFrame, cornerWidth: radius, cornerHeight: radius, transform: nil)) ctx.fillPath() ctx.setFillColor(tintColor.cgColor) if !blockQuote.isCode { let iconSize = quoteIcon.backingSize let quoteRect = CGRect(origin: CGPoint(x: blockFrame.maxX - 4.0 - iconSize.width, y: blockFrame.minY + 4.0), size: iconSize) ctx.saveGState() ctx.translateBy(x: quoteRect.midX, y: quoteRect.midY) ctx.scaleBy(x: 1.0, y: -1.0) ctx.translateBy(x: -quoteRect.midX, y: -quoteRect.midY) ctx.clip(to: quoteRect, mask: quoteIcon) ctx.fill(quoteRect) ctx.restoreGState() ctx.resetClip() } let lineFrame = CGRect(origin: CGPoint(x: blockFrame.minX, y: blockFrame.minY), size: CGSize(width: lineWidth, height: blockFrame.height)) ctx.move(to: CGPoint(x: lineFrame.minX, y: lineFrame.minY + radius)) ctx.addArc(tangent1End: CGPoint(x: lineFrame.minX, y: lineFrame.minY), tangent2End: CGPoint(x: lineFrame.minX + radius, y: lineFrame.minY), radius: radius) ctx.addLine(to: CGPoint(x: lineFrame.minX + radius, y: lineFrame.maxY)) ctx.addArc(tangent1End: CGPoint(x: lineFrame.minX, y: lineFrame.maxY), tangent2End: CGPoint(x: lineFrame.minX, y: lineFrame.maxY - radius), radius: radius) ctx.closePath() ctx.clip() if let secondaryTintColor = secondaryTintColor { let isMonochrome = secondaryTintColor.alpha == 0.2 do { ctx.saveGState() let dashHeight: CGFloat = tertiaryTintColor != nil ? 6.0 : 9.0 let dashOffset: CGFloat if let _ = tertiaryTintColor { dashOffset = isMonochrome ? -2.0 : 0.0 } else { dashOffset = isMonochrome ? -4.0 : 5.0 } if isMonochrome { ctx.setFillColor(tintColor.withMultipliedAlpha(0.2).cgColor) ctx.fill(lineFrame) ctx.setFillColor(tintColor.cgColor) } else { ctx.setFillColor(tintColor.cgColor) ctx.fill(lineFrame) ctx.setFillColor(secondaryTintColor.cgColor) } func drawDashes() { ctx.translateBy(x: blockFrame.minX, y: blockFrame.minY + dashOffset) var offset = 0.0 while offset < blockFrame.height { ctx.move(to: CGPoint(x: 0.0, y: 3.0)) ctx.addLine(to: CGPoint(x: lineWidth, y: 0.0)) ctx.addLine(to: CGPoint(x: lineWidth, y: dashHeight)) ctx.addLine(to: CGPoint(x: 0.0, y: dashHeight + 3.0)) ctx.closePath() ctx.fillPath() ctx.translateBy(x: 0.0, y: 18.0) offset += 18.0 } } drawDashes() ctx.restoreGState() if let tertiaryTintColor = tertiaryTintColor{ ctx.saveGState() ctx.translateBy(x: 0.0, y: dashHeight) if isMonochrome { ctx.setFillColor(tintColor.withAlphaComponent(0.4).cgColor) } else { ctx.setFillColor(tertiaryTintColor.cgColor) } drawDashes() ctx.restoreGState() } } } else { ctx.setFillColor(tintColor.cgColor) ctx.fill(lineFrame) } ctx.resetClip() if let header = blockQuote.header { let headerHeight = blockQuote.headerInset + 2 ctx.setFillColor(blockQuote.colors.main.withAlphaComponent(0.2).cgColor) let rect = NSMakeRect(blockFrame.minX, blockFrame.minY, blockFrame.width, headerHeight) blockHeaderRects.append((rect, blockQuote.range)) ctx.drawRoundedRect(rect: rect, topLeftRadius: radius, topRightRadius: radius) header.1.draw(CGRect(x: blockFrame.minX + 8, y: blockFrame.minY + (headerHeight - header.0.size.height) / 2 - 1, width: header.0.size.width, height: header.0.size.height), in: ctx, backingScaleFactor: backingScaleFactor, backgroundColor: .clear) if let image = NSImage(named: "Icon_CopyCode")?.precomposed(blockQuote.colors.main, flipVertical: true) { ctx.draw(image, in: CGRect(origin: NSMakePoint(blockFrame.width - image.backingSize.width - 3, blockFrame.minY + (headerHeight - image.backingSize.height) / 2), size: image.backingSize)) } } } } let spoilerRects = layout.spoilerRects(!inAnimation).map { $0.0 } if !spoilerRects.isEmpty { ctx.beginPath() ctx.addRects(spoilerRects) ctx.closePath() ctx.addRect(CGRect(x: 0, y: 0, width: layer.frame.width, height: layer.frame.height)) ctx.clip(using: .evenOdd) } for stroke in layout.strokeRects { ctx.setFillColor(stroke.1.cgColor) ctx.fill(stroke.0) } for hexColor in layout.hexColorsRect { ctx.setFillColor(hexColor.1.cgColor) ctx.fill(hexColor.0) } for i in 0 ..< layout.lines.count { let line = layout.lines[i] var penOffset = CGFloat( CTLineGetPenOffsetForFlush(line.line, line.penFlush, Double(frame.width))) if layout.penFlush == 0.5, line.penFlush != 0.5 { penOffset = startPosition.x } if line.penFlush == 1.0 { if let size = layout.cutout?.topRight { if line.frame.maxY <= size.height { penOffset -= size.width } } } var additionY: CGFloat = 0 if layout.isBigEmoji { additionY -= 4 } let insideBlock = layout.blockQuotes.first(where: { block in if block.frame.contains(line.frame.origin) { return true } else { return false } }) if let _ = insideBlock, line.isRTL { penOffset -= 20 + 4 * 2 } // if line.isRTL { // if let _ = insideBlock { // rect.origin.x -= 4 * 2 * 2 // } // } let textPosition = CGPoint(x: penOffset + line.frame.minX, y: startPosition.y + line.frame.minY + additionY) let glyphRuns = CTLineGetGlyphRuns(line.line) as NSArray if glyphRuns.count != 0 { for run in glyphRuns { let run = run as! CTRun let glyphCount = CTRunGetGlyphCount(run) let range = CTRunGetStringRange(run) let under = line.embeddedItems.contains(where: { value in return value.range == NSMakeRange(range.location, range.length) }) ctx.textPosition = textPosition if !under { CTRunDraw(run, ctx, CFRangeMake(0, glyphCount)) } } } for strikethrough in line.strikethrough { ctx.setFillColor(strikethrough.color.cgColor) ctx.fill(NSMakeRect(strikethrough.frame.minX, line.frame.minY - line.frame.height / 2 + 2, strikethrough.frame.width, .borderSize)) } } // for spoiler in layout.spoilerRects(!inAnimation) { // ctx.clear(spoiler) // } } self.blockHeaderRects = blockHeaderRects } public override func rightMouseDown(with event: NSEvent) { if let layout = textLayout, userInteractionEnabled, layout.mayItems, mouseInside() { let location = convert(event.locationInWindow, from: nil) if (!layout.selectedRange.hasSelectText || !layout.inSelectedRange(location)) && (!layout.alwaysStaticItems || layout.link(at: location) != nil) { layout.selectWord(at : location) } self.setNeedsDisplayLayer() if (layout.selectedRange.hasSelectText && isSelectable) || !layout.alwaysStaticItems { let link = layout.link(at: convert(event.locationInWindow, from: nil)) if let menuItems = layout.interactions.menuItems?(link?.1) { let window = layout.interactions.topWindow?() ?? .single(nil) menuDisposable.set(combineLatest(queue: .mainQueue(), menuItems, window).start(next:{ [weak self] items, topWindow in if let strongSelf = self { let menu = ContextMenu() for item in items { menu.addItem(item) } menu.topWindow = topWindow AppMenu.show(menu: menu, event: event, for: strongSelf) } })) } else { let window = (layout.interactions.topWindow?() ?? .single(nil)) |> deliverOnMainQueue menuDisposable.set(window.start(next: { [weak self] topWindow in let link = layout.link(at: location) let resolved: String? = link != nil ? layout.interactions.resolveLink(link!.0) : nil let menu = ContextMenu() let copy = ContextMenuItem(link?.1 != nil ? layout.interactions.localizeLinkCopy(link!.1) : localizedString("Text.Copy"), handler: { [weak self] in guard let `self` = self else {return} if let resolved = resolved { let pb = NSPasteboard.general pb.clearContents() pb.declareTypes([.string], owner: self) pb.setString(resolved, forType: .string) } else { self.copy(self) } }, itemImage: TextView.context_copy_animation) menu.addItem(copy) if resolved == nil, let window = self?._window { if let text = self?.effectiveText, let translate = layout.interactions.translate?(text, window) { menu.addItem(translate) } } menu.topWindow = topWindow if let strongSelf = self { AppMenu.show(menu: menu, event: event, for: strongSelf) } })) } } else { layout.selectedRange.range = NSMakeRange(NSNotFound, 0) setNeedsDisplayLayer() super.rightMouseDown(with: event) } } else { super.rightMouseDown(with: event) } } public static var context_copy_animation: ((NSColor, ContextMenuItem)->AppMenuItemImageDrawable)? public func menuDidClose(_ menu: NSMenu) { } /* var view: NSTextView? = (self.window?.fieldEditor(true, forObject: self) as? NSTextView) view?.isEditable = false view?.isSelectable = true view?.string = layout.attributedString.string view?.selectedRange = NSRange(location: 0, length: view?.string?.length) NSMenu.popUpContextMenu(view?.menu(for: event), with: event, for: view) */ public override func menu(for event: NSEvent) -> NSMenu? { return nil } deinit { menuDisposable.dispose() NotificationCenter.default.removeObserver(self) } public func isEqual(to layout:TextViewLayout) -> Bool { return self.textLayout == layout } public override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() self.drawLayer.contentsScale = backingScaleFactor self.embeddedContainer.contentsScale = backingScaleFactor setNeedsDisplayLayer() } // private func updateInks(_ layout: TextViewLayout?, animated: Bool = false) { if let layout = layout { let spoilers = layout.spoilers let rects = layout.spoilerRects().map { $0.0 } while rects.count > self.inkViews.count { let inkView = InvisibleInkDustView() self.inkViews.append(inkView) self.addSubview(inkView) } if rects.count < self.inkViews.count, animated { let fake = TextView(frame: self.bounds) fake.update(layout) fake.userInteractionEnabled = false fake.isSelectable = false addSubview(fake, positioned: .below, relativeTo: self.subviews.first) fake.layer?.animateAlpha(from: 0, to: 1, duration: 0.2, timingFunction: .easeInEaseOut, removeOnCompletion: false, completion: { [weak fake, weak self] _ in fake?.removeFromSuperview() self?.inAnimation = false }) self.inAnimation = true fake.clearExceptRevealed = true } while rects.count < self.inkViews.count { performSubviewRemoval(self.inkViews.removeLast(), animated: animated) } for (i, inkView) in inkViews.enumerated() { let rect = rects[i] let color = spoilers[0].color inkView.update(size: rect.size, color: color, textColor: color, rects: [rect.size.bounds], wordRects: [rect.size.bounds.insetBy(dx: 2, dy: 2)]) inkView.frame = rect } } else { while !inkViews.isEmpty { performSubviewRemoval(inkViews.removeLast(), animated: animated) } } self.checkEmbeddedUnderSpoiler() self.selectionWasCleared = false } public func update(_ layout:TextViewLayout?, origin:NSPoint? = nil, transition: ContainedViewLayoutTransition = .immediate) -> Void { if let current = self.textLayout, current.attributedString.string == layout?.attributedString.string { if layout?.selectedRange.range == NSMakeRange(NSNotFound, 0) { layout?.selectedRange = current.selectedRange } } else { self.animatedView?.removeFromSuperlayer() self.animatedView = nil } self.textLayout = layout self.updateInks(layout) if let layout = layout { self.set(selectedRange: layout.selectedRange.range, display: false) let point:NSPoint if let origin = origin { point = origin } else { point = frame.origin } let rect = NSMakeRect(point.x, point.y, layout.layoutSize.width + layout.insets.width, layout.layoutSize.height + layout.insets.height) transition.updateFrame(view: self, frame: rect) removeAllToolTips() for rect in layout.toolTipRects { addToolTip(rect, owner: self, userData: nil) } } else { self.set(selectedRange: NSMakeRange(NSNotFound, 0), display: false) transition.updateFrame(view: self, frame: NSZeroRect) } updateBackgroundBlur() self.setNeedsDisplayLayer() } public func set(layout:TextViewLayout?) { self.textLayout = layout self.setNeedsDisplayLayer() } public override func setNeedsDisplayLayer() { self.layer?.setNeedsDisplay() self.drawingLayer?.setNeedsDisplay() // self.drawLayer.displayIfNeeded() } public override func setNeedsDisplay() { self.layer?.setNeedsDisplay() self.drawingLayer?.setNeedsDisplay() } func set(selectedRange range:NSRange, display:Bool = true) -> Void { if let layout = textLayout { layout.selectedRange = TextSelectedRange(range:range, color: layout.selectText, def:true) } beginSelect = NSMakePoint(-1, -1) endSelect = NSMakePoint(-1, -1) if display { self.setNeedsDisplayLayer() } } public override func mouseDown(with event: NSEvent) { if event.modifierFlags.contains(.control) { rightMouseDown(with: event) return } if isSelectable && !event.modifierFlags.contains(.shift) { self.window?.makeFirstResponder(nil) } if let layout = textLayout, userInteractionEnabled { let point = self.convert(event.locationInWindow, from: nil) let index = layout.findIndex(location: point) if point.x > layout.lines[index].frame.maxX, isSelectable { superview?.mouseDown(with: event) if sendDownAnyway { super.mouseDown(with: event) } } else { _mouseDown(with: event) } } else if !userInteractionEnabled { _mouseDown(with: event) } selectionWasCleared = false } public override func viewWillMove(toWindow newWindow: NSWindow?) { if let newWindow = newWindow { NotificationCenter.default.addObserver(self, selector: #selector(windowDidBecomeKey), name: NSWindow.didBecomeKeyNotification, object: newWindow) NotificationCenter.default.addObserver(self, selector: #selector(windowDidResignKey), name: NSWindow.didResignKeyNotification, object: newWindow) } else { NotificationCenter.default.removeObserver(self, name: NSWindow.didBecomeKeyNotification, object: window) NotificationCenter.default.removeObserver(self, name: NSWindow.didResignKeyNotification, object: window) } } @objc open func windowDidBecomeKey() { setNeedsDisplayLayer() } @objc open func windowDidResignKey() { setNeedsDisplayLayer() } private var locationInWindow:NSPoint? = nil public var sendDownAnyway: Bool = false func _mouseDown(with event: NSEvent) -> Void { self.locationInWindow = event.locationInWindow if !isSelectable || !userInteractionEnabled || event.modifierFlags.contains(.shift) { super.mouseDown(with: event) return } else if sendDownAnyway { super.mouseDown(with: event) } _ = self.becomeFirstResponder() set(selectedRange: NSMakeRange(NSNotFound, 0), display: false) self.beginSelect = self.convert(event.locationInWindow, from: nil) self.setNeedsDisplayLayer() } public override func mouseDragged(with event: NSEvent) { super.mouseDragged(with: event) checkCursor(event) _mouseDragged(with: event) } func _mouseDragged(with event: NSEvent) -> Void { if !isSelectable || !userInteractionEnabled { return } if let locationInWindow = self.locationInWindow { let old = (ceil(locationInWindow.x), ceil(locationInWindow.y)) let new = (ceil(event.locationInWindow.x), round(event.locationInWindow.y)) if abs(old.0 - new.0) <= 1 && abs(old.1 - new.1) <= 1 { return } } endSelect = self.convert(event.locationInWindow, from: nil) if let layout = textLayout { layout.selectedRange.range = layout.selectedRange(startPoint: beginSelect, currentPoint: endSelect, byWord: event.clickCount == 2) layout.selectedRange.cursorAlignment = beginSelect.x > endSelect.x ? .min(layout.selectedRange.range.max) : .max(layout.selectedRange.range.min) } self.setNeedsDisplayLayer() } public override func mouseEntered(with event: NSEvent) { if userInteractionEnabled { checkCursor(event) } else { super.mouseEntered(with: event) } } public override func mouseExited(with event: NSEvent) { if userInteractionEnabled { checkCursor(event) } else { super.mouseExited(with: event) } } public override func mouseMoved(with event: NSEvent) { if userInteractionEnabled { checkCursor(event) } else { super.mouseMoved(with: event) } } public override func mouseUp(with event: NSEvent) { self.locationInWindow = nil if let layout = textLayout, userInteractionEnabled, let window = _window { let point = self.convert(event.locationInWindow, from: nil) if let _ = layout.spoiler(at: point) { layout.revealSpoiler() setNeedsDisplayLayer() self.updateInks(layout, animated: true) return } if let blockHeader = blockHeaderRects.first(where: { $0.0.contains(point) }) { var quoteRange = blockHeader.1 for i in blockHeader.1.max ..< layout.attributedString.length { let value = layout.attributedString.attribute(TextInputAttributes.quote, at: i, effectiveRange: nil) if value != nil { quoteRange.length += 1 } else { break } } let string = layout.attributedString.attributedSubstring(from: quoteRange) _ = layout.interactions.copyToClipboard?(string.string) showModalText(for: window, text: localizedString("Share.Link.Copied")) } else if event.clickCount == 3, isSelectable { layout.selectAll(at: point) layout.selectedRange.cursorAlignment = .max(layout.selectedRange.range.min) } else if isSelectable, event.clickCount == 2 || (event.type == .rightMouseUp && !layout.selectedRange.hasSelectText) { layout.selectWord(at : point) layout.selectedRange.cursorAlignment = .max(layout.selectedRange.range.min) } else if !layout.selectedRange.hasSelectText || !isSelectable && (event.clickCount == 1 || !isSelectable) { if let color = layout.color(at: point), let copyToClipboard = layout.interactions.copyToClipboard { copyToClipboard(color.0.hexString.lowercased()) } else if let (link, _, _, _) = layout.link(at: point) { if event.clickCount == 1 { layout.interactions.processURL(link) } } else { if layout.findCharacterIndex(at: point) == -1, onlyTextIsInteractive { moveNextEventDeep = true } super.mouseUp(with: event) } } else if layout.selectedRange.hasSelectText && event.clickCount == 1 && event.modifierFlags.contains(.shift) { var range = layout.selectedRange.range let index = layout.findCharacterIndex(at: point) if index < range.min { range.length += (range.location - index) range.location = index } else if index > range.max { range.length = (index - range.location) } layout.selectedRange.range = range } else { super.mouseUp(with: event) } setNeedsDisplayLayer() } else { super.mouseUp(with: event) } self.beginSelect = NSMakePoint(-1, -1) } public override func cursorUpdate(with event: NSEvent) { if userInteractionEnabled { checkCursor(event) } else { super.cursorUpdate(with: event) } } func checkCursor(_ event:NSEvent) -> Void { let location = self.convert(event.locationInWindow, from: nil) if self.isMousePoint(location , in: self.visibleRect) && mouseInside() && userInteractionEnabled { let inBlockHeader = blockHeaderRects.contains(where: { $0.0.contains(location) }) if inBlockHeader { NSCursor.pointingHand.set() } else if textLayout?.spoiler(at: location) != nil { NSCursor.pointingHand.set() } else if textLayout?.color(at: location) != nil { NSCursor.pointingHand.set() textLayout?.interactions.hoverOnLink(.exited) } else if let layout = textLayout, let (value, _, _, _) = layout.link(at: location) { NSCursor.pointingHand.set() layout.interactions.hoverOnLink(.entered(value)) } else if isSelectable { NSCursor.iBeam.set() textLayout?.interactions.hoverOnLink(.exited) } else { NSCursor.arrow.set() textLayout?.interactions.hoverOnLink(.exited) } } else { NSCursor.arrow.set() textLayout?.interactions.hoverOnLink(.exited) } } private func updateBackgroundBlur() { self.layer?.masksToBounds = blurBackground != nil if let blurBackground = blurBackground { if self.visualEffect == nil { self.visualEffect = VisualEffect(frame: self.bounds) addSubview(self.visualEffect!, positioned: .below, relativeTo: nil) if let textView = self.textView { self.visualEffect?.addSubview(textView) } else { self.visualEffect?.layer?.addSublayer(drawLayer) } self.visualEffect?.layer?.addSublayer(embeddedContainer) } self.visualEffect?.bgColor = blurBackground if let textlayout = self.textLayout, let blockImage = textlayout.blockImage.1 { if blockMask == nil { blockMask = SimpleLayer() } var fr = CATransform3DIdentity fr = CATransform3DTranslate(fr, blockImage.backingSize.width / 2, 0, 0) fr = CATransform3DScale(fr, 1, -1, 1) fr = CATransform3DTranslate(fr, -(blockImage.backingSize.width / 2), 0, 0) blockMask?.transform = fr blockMask?.contentsScale = 2.0 blockMask?.contents = blockImage blockMask?.frame = CGRect(origin: .zero, size: blockImage.backingSize) self.layer?.mask = blockMask } else { self.blockMask = nil self.layer?.mask = nil } } else { if let textView = textView { self.addSubview(textView, positioned: .below, relativeTo: nil) } else { if let animatedView = animatedView, animatedView.superlayer != nil { self.layer?.insertSublayer(drawLayer, at: 1) } else { self.layer?.insertSublayer(drawLayer, at: 0) } } self.layer?.insertSublayer(embeddedContainer, at: 1) self.visualEffect?.removeFromSuperview() self.visualEffect = nil self.blockMask?.removeFromSuperlayer() self.blockMask = nil self.layer?.mask = nil } needsLayout = true } public override var needsDisplay: Bool { didSet { setNeedsDisplayLayer() } } public func addEmbeddedLayer(_ layer: CALayer) { embeddedContainer.addSublayer(layer) } public override func layout() { super.layout() self.visualEffect?.frame = bounds self.textView?.frame = bounds self.embeddedContainer.frame = bounds self.drawLayer.frame = bounds self.drawLayer.bounds = bounds.insetBy(dx: -2, dy: -3) self.updateInks(self.textLayout) } public func resize(_ width: CGFloat, blockColor: NSColor? = nil) { self.textLayout?.measure(width: width) if let blockColor = blockColor { self.textLayout?.generateAutoBlock(backgroundColor: blockColor) } self.update(self.textLayout) } public override func becomeFirstResponder() -> Bool { if canBeResponder { if let window = self.window { return window.makeFirstResponder(self) } } return false } public override func accessibilityLabel() -> String? { return self.textLayout?.attributedString.string } // public override var isOpaque: Bool { // return false // } public override func resignFirstResponder() -> Bool { _resignFirstResponder() return super.resignFirstResponder() } func _resignFirstResponder() -> Void { self.set(selectedRange: NSMakeRange(NSNotFound, 0)) } public override func responds(to aSelector: Selector!) -> Bool { if NSStringFromSelector(aSelector) == "copy:" { return self.textLayout?.selectedRange.range.location != NSNotFound } return super.responds(to: aSelector) } private var effectiveText: String? { if let layout = textLayout { if layout.selectedRange.range.location != NSNotFound { return layout.attributedString.string.nsstring.substring(with: layout.selectedRange.range) } else { return layout.attributedString.string } } else { return nil } } @objc public func copy(_ sender:Any) -> Void { if let layout = textLayout { if let copy = layout.interactions.copy { if !copy() && layout.selectedRange.range.location != NSNotFound { if !layout.interactions.copyAttributedString(layout.attributedString.attributedSubstring(from: layout.selectedRange.range)) { let pb = NSPasteboard.general pb.clearContents() pb.declareTypes([.string], owner: self) pb.setString(layout.attributedString.string.nsstring.substring(with: layout.selectedRange.range), forType: .string) } } } else if layout.selectedRange.range.location != NSNotFound { if !layout.interactions.copyAttributedString(layout.attributedString.attributedSubstring(from: layout.selectedRange.range)) { let pb = NSPasteboard.general pb.clearContents() pb.declareTypes([.string], owner: self) pb.setString(layout.attributedString.string.nsstring.substring(with: layout.selectedRange.range), forType: .string) } } } } public func checkEmbeddedUnderSpoiler() { if let layout = self.textLayout { let rects = layout.spoilerRects().map { $0.0 } let sublayers = embeddedContainer.sublayers ?? [] for subview in sublayers { var isHidden = false loop: for rect in rects { if NSIntersectsRect(NSMakeRect(subview.frame.midX, subview.frame.midY, 1, 1), rect) { isHidden = true break loop } } subview.opacity = isHidden ? 0 : 1 } } } @objc func paste(_ sender:Any) { } public override func removeFromSuperview() { super.removeFromSuperview() } public func updateWithNewWidth(_ width: CGFloat) { let layout = self.textLayout layout?.measure(width: width) self.update(layout) } required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } private var animatedView: SimpleLayer? public func highlight(text: String, color: NSColor) { guard let textLayout = self.textLayout else { return } self.animatedView?.removeFromSuperlayer() let range = textLayout.attributedString.string.nsstring.range(of: text) if range.location != NSNotFound { let image = textLayout.generateBlock(for: range, backgroundColor: color) let imageView = SimpleLayer(frame: CGRect(origin: image.0, size: image.1!.backingSize)) imageView.contents = image.1 imageView.opacity = 0.5 self.layer?.insertSublayer(imageView, at: 0) self.animatedView = imageView _ = delaySignal(3.0).start(completed: { [weak self] in if let view = self?.animatedView { performSublayerRemoval(view, animated: true) self?.animatedView = nil } }) } } } public extension TextView { func setIsShimmering(_ value: Bool, animated: Bool) { if value, let blockImage = textLayout?.maskBlockImage.1 { let size = blockImage.size let current: ShimmerView if let view = self.shimmerEffect { current = view } else { current = ShimmerView() self.shimmerEffect = current self.addSubview(current) if animated { current.layer?.animateAlpha(from: 0, to: 1, duration: 0.2) } } current.update(backgroundColor: .blackTransparent, data: nil, size: size, imageSize: size) current.updateAbsoluteRect(size.bounds, within: size) let frame = self.bounds current.frame = blockImage.backingSize.bounds.offsetBy(dx: frame.minX - 5, dy: frame.minY - 1) if let blockImage = textLayout?.maskBlockImage.1 { if shimmerMask == nil { shimmerMask = SimpleLayer() } var fr = CATransform3DIdentity fr = CATransform3DTranslate(fr, blockImage.backingSize.width / 2, 0, 0) fr = CATransform3DScale(fr, 1, -1, 1) fr = CATransform3DTranslate(fr, -(blockImage.backingSize.width / 2), 0, 0) shimmerMask?.transform = fr shimmerMask?.contentsScale = 2.0 shimmerMask?.contents = blockImage shimmerMask?.frame = CGRect(origin: .zero, size: blockImage.backingSize) current.layer?.mask = shimmerMask } else { self.shimmerMask = nil current.layer?.mask = nil } } else { if let view = self.shimmerEffect { let shimmerMask = self.shimmerMask performSubviewRemoval(view, animated: animated, completed: { [weak shimmerMask] _ in shimmerMask?.removeFromSuperlayer() }) self.shimmerEffect = nil self.shimmerMask = nil } } } func reloadAnimation() { self.shimmerEffect?.reloadAnimation() } }