Files
2024-07-01 19:16:14 -03:00

3471 lines
144 KiB
Swift

//
// 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<Window?, NoError>)? = 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<Window?, NoError>)? = 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()
}
}