Files
divkit/client/ios/Core/BaseUI/NSAttributedStringExtensions.swift
T
divkit-github f1563c9890 chore(html-reporter): up version
Initial commit

Add notification for divkit

Allow overwrite for migration

[migration] browser/bitbucket/personal/booster/divkit

Note: mandatory check (NEED_CHECK) was skipped

DivKit files moved

dIVKIT-0: Fix ts builder build

update div-gallery visibility screenshots

update div-gallery visibility screens

a.yaml cleanup

updated codegen scripts

change gallery alignment

change internal types in expressions

removed copyrights

remove permissions

TemplatesSupportTests refactoring

Ничего не поменялось. Отрефакторил и почистил тесты.

Split package.json

send all public

removed yandex-team links

URL added type as ArgumentType

added camera permission description

safe area insets for errors count view

`EdgeInsets` for debug view with errors count. Needed for fullscreen divs. Naming corresponds to mapping `EdgeInsets` to div-variables.

Will be used here: https://nda.ya.ru/t/Lhr5Lxgb5KN6nX

fixed compiler warnings

fix DivKitComponents variables setup, add update func

fix DivKitComponents variables setup, add update func

authors clean up

Удалил коллег из инфраструктуры, тестирования и мессенджера. Половину людей я вообще тут не знаю. Список какой-то косячный, в нём, например, @booster и @askvortsov нет. Предлагаю заново вручную составить и записать туда тех, кто точно дивкитом занимался, а не просто мимо проходил.

Fix DivKit pods compilability

Fix DivKit pods compilability

use only english in demo app

translate divKit demo app russian text

Scroll when keyboard opened

Scroll when keyboard opened

Fix gallery scroll in a11y

Это копия пр-а, который я делал в репо алискита:

https://bitbucket.browser.yandex-team.ru/projects/ML/repos/mobile-alice-library-android/pull-requests/18808/overview

authors clean up2

SwiftGenerator

flake8 errors fix & ya.make edit

swift generation tests

swift references

swift generator implementation

init swift generator

code refactoring
1) rename to modeling
2) rename kind to PropertyType
3) delete apply_pluralization_rules
4) description default value is empty string
5) remove entity_type_field_alias, typename_alias, client_optional, profileEntities, ignore_properties, generate_plain_constructor
6) remove client prefix

Add XcodeBuild in ignore

Add XcodeBuild in ignore

introduce new global variable api

introduce new global variable api

Revert commit bdcbc0081395ebe44ed7fe24bf5f4b25569c6df0

LayoutKitSnapshotTests

Fix sandboxRelease dependencies.

support zero value for width and height

remove unused css variables

"CONTRIBUTING.md"

README draft

updated gitignore/arcignore

introduce color type in expressions

introduce color type in expressions

added xcconfig files

fixed swift formatting

fail tests if updating references

Чтобы уж точно не забыть вернуть False.

Layout changes

Fix expression parsing

don't change clickable state in a11y

don't change clickable state in a11y

Update items checks

Parse description references to translations

add licence file link

fixed expression properties generation

add ability to track requests to global variables

Добавляем в GlobalVariableController возможность подписаться на запросы к глобальным переменным.
Нужно дляОткрыт Накопительная палитра , а так же в целом полезная функциональность, через которую можно делать ленивый прогрев глобальных переменных

Changelog

Minor fixes

bump divkit version up to 3.0.0

fixed snapshot tests depdendencies

Иногда не запускалась сборка SnapshotTestsHostApp перед стартом тестов.

used new api-generator

deserialization refactoring

removed gitignore

samples text fixes

fixed samples text

remove theme button

remove theme button

demo activity redesign

demo activity redesign

habr sample app

change deleted images in screenshot tests

change deleted images in screenshot tests

new ic_launcher

new ic_launcher

more readme

Binding variable for div input

Biding variable for div input

Implement documentation generator

Rename max_lines to max_visible_lines

Rename max_lines to max_visible_lines

add habr and medium

fix div-functions doc

Fix link in description

Text selecting for demo app enabled

Text selecting for demo app enabled

KotlinGenerator

kotlin code generation

kotlin generation tests

kotlin generation references

add gradle wrapper to kotlin json builder

add gradle wrapper to kotlin json builder

DemoApp redesign

fixed codegen scripts

debugView safeAreaInsets

DebugView safe area insets

TypeScript Generator

ya.make changes

TypeScript generator

TypeScript tests

TypeScript test references

fix enumeration aliases

debugInfoParams fix compile

Fix black box flicker in video-custom

Fixed stub image showing by moving it away from PlayerView.
Increased stub loading speed by adding VideoViewModel warmup.
Fixed redundant redraws by combining stub visibility and
bitmap data.
PlayerView background was set to transparent.

DivExpression fix

DivExpression fix

Implement translations

Save & share

bind globalVariableController to divContext

bind variableController to divContext

used new TypeScript generator

Подключил генератор на питоне. Изменения в сгенерированных файлах связаны в основном с переводом описаний на английский язык.

moved LivePreview

Перенёс LivePreview из отдельного модуля в DivKitDemoApp. Добавил сканер кодов в UrlInputView. Удалил часть дублирующегося кода.

remove sms and phone_state permission

fix links interception

fix links interception

Fix link in documentation

remove site from "config.stable.json"

Support highlight_color and keyboardType in div input

Support highlight_color in div input

Action animation fix for iOS
2022-08-25 21:21:49 +03:00

1143 lines
33 KiB
Swift

// Copyright 2021 Yandex LLC. All rights reserved.
import CoreGraphics
import CoreText
import Foundation
import BaseTiny
extension NSAttributedString {
public enum VerticalPosition {
case top
case center
case bottom
}
public func sizeForWidth(_ width: CGFloat) -> CGSize {
sizeForString(self, CGSize(width: width, height: .infinity), Int.max)
}
public func heightForWidth(
_ width: CGFloat,
maxNumberOfLines: Int,
minNumberOfHiddenLines: Int = 0
) -> CGFloat {
let maxTextSize = CGSize(width: width, height: .infinity)
if minNumberOfHiddenLines > 0 {
let layout = computeLayout(
for: self,
maxTextSize: maxTextSize,
maxNumberOfLines: Int.max
)
if layout.lines.count < maxNumberOfLines + minNumberOfHiddenLines {
return layout.height
}
}
return sizeForString(self, maxTextSize, maxNumberOfLines).height
}
public func heightForWidth(_ width: CGFloat, maxTextHeight: CGFloat) -> CGFloat {
sizeForString(self, CGSize(width: width, height: maxTextHeight), Int.max).height
}
public func sizeThatFits(_ size: CGSize, maxNumberOfLines: Int) -> CGSize {
sizeForString(self, size, maxNumberOfLines)
}
}
public func measureString(
_ string: NSAttributedString,
maxTextSize: CGSize,
maxNumberOfLines: Int = .max
) -> (size: CGSize, numberOfLines: Int) {
let linesConstraint = (maxNumberOfLines > 0) ? maxNumberOfLines : .max
let layout = computeLayout(
for: string,
maxTextSize: maxTextSize,
maxNumberOfLines: linesConstraint
)
return (layout.size, layout.lines.count)
}
private let sizeForString =
memoizeAClass { (string: NSAttributedString, maxTextSize: CGSize, maxNumberOfLines: Int) -> CGSize in
let layout = computeLayout(
for: string,
maxTextSize: maxTextSize,
maxNumberOfLines: maxNumberOfLines
)
return layout.size
}
private func computeLayout(
for string: NSAttributedString,
maxTextSize: CGSize,
maxNumberOfLines: Int
) -> TextLayout {
if string.containsSoftHyphens {
let layout = string
.makeCopyWithoutSoftHyphens()
.makeTextLayout(
in: maxTextSize,
maxNumberOfLines: maxNumberOfLines,
breakWords: false,
truncationToken: nil
)
if layout.entireTextFits(maxTextSize) {
return layout
}
}
return string.makeTextLayout(
in: maxTextSize,
maxNumberOfLines: maxNumberOfLines,
breakWords: true,
truncationToken: nil
)
}
struct TypographicBounds {
var ascent: CGFloat
var descent: CGFloat
let width: CGFloat
static let `default` = TypographicBounds(
ascent: Font.systemFontWithDefaultSize().ascender,
descent: abs(Font.systemFontWithDefaultSize().descender),
width: 0
)
var height: CGFloat {
ascent + descent
}
func constrained(width maxWidth: CGFloat) -> TypographicBounds {
TypographicBounds(
ascent: ascent,
descent: descent,
width: min(width, maxWidth)
)
}
}
typealias LineLayout = (line: CTLine, bounds: TypographicBounds, range: NSRange)
struct TextLayout {
var lines: [LineLayout]
private var sourceLength: Int
var width: CGFloat {
lines.map { $0.bounds.width }.max() ?? 0
}
var height: CGFloat {
lines.reduce(0) { $0 + $1.bounds.height }
}
var size: CGSize {
CGSize(width: width, height: height).ceiled()
}
var range: NSRange {
let location = lines.first?.range.location ?? 0
return NSRange(location: location, length: textLength)
}
var textLength: Int {
lines.reduce(0) { $0 + $1.range.length }
}
init(lines: [LineLayout], sourceLength: Int) {
self.lines = lines
self.sourceLength = sourceLength
}
func entireTextFits(_ size: CGSize) -> Bool {
sourceLength == textLength &&
width <= size.width &&
height <= size.height
}
}
private let ellipsis = "\u{2026}"
private let softHyphen: UTF16.CodeUnit = 0x00_AD
private let lineFeed: UTF16.CodeUnit = 0x00_0A
private let carriageReturn: UTF16.CodeUnit = 0x00_0D
extension NSAttributedString {
var containsSoftHyphens: Bool {
string.utf16.contains(softHyphen)
}
func makeTextLayout(
in size: CGSize,
maxNumberOfLines: Int = .max,
breakWords: Bool,
truncationToken: NSAttributedString? = nil
) -> TextLayout {
let typesetter = CTTypesetterCreateWithAttributedString(self)
var offset = 0
var lines = [LineLayout]()
var textHeight: CGFloat = 0
while offset < length, lines.count < maxNumberOfLines {
let lineLength = breakWords ?
typesetter.lineLength(from: offset, constrainedTo: size.width) :
typesetter.lineLengthByWordBoundary(for: string, from: offset, constrainedTo: size.width)
guard lineLength > 0 else {
break
}
let suggestedRange = CFRange(location: offset, length: lineLength)
let layout: LineLayout
if subrangeEndsWithSoftHyphen(suggestedRange.nsRange) {
assert(breakWords)
layout = layoutHyphenatedLine(
typesetter: typesetter,
range: suggestedRange,
availableWidth: size.width
)
} else {
layout = layoutLine(
typesetter: typesetter,
range: suggestedRange
)
}
textHeight += layout.bounds.height
let fitsByHeight = textHeight.isApproximatelyLessOrEqualThan(size.height)
let nextLineFitsByLineNumber = (lines.count < maxNumberOfLines - 1 && !singleLineBreakMode) ||
suggestedRange.nsRange.endIndex == length
if fitsByHeight, nextLineFitsByLineNumber {
lines.append(layout)
offset += layout.range.length
} else if !nextLineFitsByLineNumber {
if let lastLine = makeTruncatedLine(
from: suggestedRange.nsRange,
constrainedTo: size.width,
truncationToken: truncationToken
) {
lines.append((lastLine, lastLine.typographicBounds, suggestedRange.nsRange))
}
break
} else {
let wholeRange = NSRange(location: 0, length: length)
let remainingRange = lines.last?.range ?? wholeRange
if let lastLine = makeTruncatedLine(
from: remainingRange,
constrainedTo: size.width,
truncationToken: truncationToken
) {
lines.removeLastIfExists()
lines.append((lastLine, lastLine.typographicBounds, suggestedRange.nsRange))
}
break
}
}
return TextLayout(lines: lines, sourceLength: length)
}
private func layoutHyphenatedLine(
typesetter: CTTypesetter,
range: CFRange,
availableWidth: CGFloat
) -> LineLayout {
let line = makeHyphenatedLine(from: range.nsRange)
let extent = line.typographicBounds.width - availableWidth
if extent > 0 {
let correctedLength = typesetter.lineLength(
from: range.location,
constrainedTo: availableWidth - extent
)
if correctedLength > 0 {
let correctedRange = CFRange(location: range.location, length: correctedLength)
if subrangeEndsWithSoftHyphen(correctedRange.nsRange) {
let correctedLine = makeHyphenatedLine(from: correctedRange.nsRange)
return (correctedLine, correctedLine.typographicBounds, correctedRange.nsRange)
} else {
return layoutLine(
typesetter: typesetter,
range: correctedRange
)
}
}
}
return (line, line.typographicBounds, range.nsRange)
}
private func layoutLine(
typesetter: CTTypesetter,
range: CFRange
) -> LineLayout {
let lineLength = range.length - trailingSymbolsTrimCount(range.nsRange)
let line = CTTypesetterCreateLine(
typesetter,
CFRange(location: range.location, length: lineLength)
)
return (line, line.typographicBounds, range.nsRange)
}
private func makeHyphenatedLine(from range: NSRange) -> CTLine {
let copyString = attributedSubstring(from: range).mutableCopy() as! NSMutableAttributedString
copyString.appendWithPreservedAttributes("-")
return CTLineCreateWithAttributedString(copyString)
}
public func draw(
inContext context: CGContext,
verticalPosition: VerticalPosition = .center,
rect: CGRect
) {
_ = drawAndGetLayout(
inContext: context,
verticalPosition: verticalPosition,
rect: rect,
actionKey: nil,
selectedRange: nil
) as AttributedStringLayout<Void>
}
public func drawAndGetLayout(
inContext context: CGContext?,
verticalPosition: VerticalPosition = .center,
rect: CGRect,
truncationToken: NSAttributedString? = nil
) -> AttributedStringLayout<Void> {
drawAndGetLayout(
inContext: context,
verticalPosition: verticalPosition,
rect: rect,
truncationToken: truncationToken,
actionKey: nil,
selectedRange: nil
)
}
public func drawAndGetLayout<ActionType>(
inContext context: CGContext?,
verticalPosition: VerticalPosition = .center,
rect: CGRect,
truncationToken: NSAttributedString? = nil,
actionKey: NSAttributedString.Key?,
selectedRange: Range<Int>?
) -> AttributedStringLayout<ActionType> {
context?.saveGState()
defer {
context?.restoreGState()
}
let transform = CGAffineTransform(translationX: 0, y: rect.height)
.scaledBy(x: 1, y: -1)
context?.concatenate(transform)
context?.textMatrix = CGAffineTransform.identity
let transformedRect = rect.applying(transform)
if containsSoftHyphens {
let copy = makeCopyWithoutSoftHyphens()
let layout = copy.makeTextLayout(
in: rect.size,
breakWords: false,
truncationToken: truncationToken
)
if layout.entireTextFits(rect.size) {
return copy.drawAndGetLayoutImpl(
inContext: context,
verticalPosition: verticalPosition,
rect: transformedRect,
truncationToken: truncationToken,
actionKey: actionKey,
selectedRange: selectedRange
)
}
}
return drawAndGetLayoutImpl(
inContext: context,
verticalPosition: verticalPosition,
rect: transformedRect,
truncationToken: truncationToken,
actionKey: actionKey,
selectedRange: selectedRange
)
}
private func drawAndGetLayoutImpl<ActionType>(
inContext context: CGContext?,
verticalPosition: VerticalPosition,
rect: CGRect,
truncationToken: NSAttributedString?,
actionKey: NSAttributedString.Key?,
selectedRange _: Range<Int>?
) -> AttributedStringLayout<ActionType> {
let layout = makeTextLayout(
in: rect.size,
breakWords: true,
truncationToken: truncationToken
)
var runsLayout = [AttributedStringLayout<ActionType>.Run]()
assert(
// accuracy is needed because sometimes text is a bit higher,
// but it's not noticeable in rendering and doesn't affect vertical position
layout.lines.count == 1 ||
rect.height.isApproximatelyGreaterOrEqualThan(layout.height, withAccuracy: 0.5),
"Layout constrained to some height should not exceed it"
)
let maybeNegativeOffset = verticalPosition.verticalOffset(
forHeight: layout.height,
availableHeight: rect.height
)
let verticalOffset = max(maybeNegativeOffset, 0)
var lineOriginY: CGFloat = rect.height - verticalOffset
var firstLineOriginX: CGFloat?
var lines: [AttributedStringLineLayout] = []
for (line, bounds, range) in layout.lines {
let lineOriginX = horizontalOffset(
lineWidth: bounds.width,
maxWidth: rect.width
)
if firstLineOriginX == nil {
firstLineOriginX = lineOriginX + rect.origin.x
}
let textPosition = CGPoint(
x: rect.origin.x + lineOriginX,
y: rect.origin.y + lineOriginY - bounds.ascent
)
if let context = context {
let lineRunsLayout = line.draw(
at: textPosition,
in: context,
layoutY: rect.maxY - lineOriginY,
actionKey: actionKey
) as [AttributedStringLayout<ActionType>.Run]
runsLayout += lineRunsLayout
} else {
break
}
lines.append(AttributedStringLineLayout(
line: line,
verticalOffset: lineOriginY,
horizontalOffset: lineOriginX,
range: range
))
lineOriginY -= bounds.height
}
return AttributedStringLayout(
firstLineOriginX: firstLineOriginX,
runsWithAction: runsLayout,
lines: lines
)
}
public func drawSelection(
context: CGContext?,
rect: CGRect,
linesLayout: [AttributedStringLineLayout],
selectedRange: Range<Int>?
) -> CGRect {
context?.saveGState()
defer {
context?.restoreGState()
}
let transform = CGAffineTransform(translationX: 0, y: rect.height)
.scaledBy(x: 1, y: -1)
context?.concatenate(transform)
context?.textMatrix = CGAffineTransform.identity
let transformedRect = rect.applying(transform)
var selectionRect = CGRect.zero
for lineLayout in linesLayout {
drawLineSelection(
context: context,
selectionRect: &selectionRect,
selectedRange: selectedRange,
rect: transformedRect,
line: lineLayout.line,
lineHeight: lineLayout.line.typographicBounds.height,
range: lineLayout.range,
lineOriginX: lineLayout.horizontalOffset,
lineOriginY: lineLayout.verticalOffset
)
}
return selectionRect
}
private func drawLineSelection(
context: CGContext?,
selectionRect: inout CGRect,
selectedRange: Range<Int>?,
rect: CGRect,
line: CTLine,
lineHeight: CGFloat,
range: NSRange,
lineOriginX: CGFloat,
lineOriginY: CGFloat
) {
guard let leadingSelectionIndex = selectedRange?.lowerBound,
let trailingSelectionIndex = selectedRange?.upperBound else {
return
}
let needDrawLeadingPointer: Bool
let needDrawTrailingPointer: Bool
let leadingSelectionOffset: CGFloat?
let trailingSelectionOffset: CGFloat?
if (range.lowerBound...range.upperBound).contains(leadingSelectionIndex),
(range.lowerBound...range.upperBound).contains(trailingSelectionIndex) {
let trailingOffset = CTLineGetOffsetForStringIndex(line, trailingSelectionIndex, nil)
let leadingOffset = CTLineGetOffsetForStringIndex(line, leadingSelectionIndex, nil)
needDrawLeadingPointer = true
needDrawTrailingPointer = true
leadingSelectionOffset = leadingOffset
trailingSelectionOffset = trailingOffset
selectionRect = CGRect(
origin: CGPoint(x: lineOriginX + leadingOffset, y: rect.height - lineOriginY),
size: CGSize(width: trailingOffset - leadingOffset, height: lineHeight)
)
} else if (range.lowerBound..<range.upperBound).contains(leadingSelectionIndex) {
let leadingOffset = CTLineGetOffsetForStringIndex(line, leadingSelectionIndex, nil)
let trailingOffset = CTLineGetOffsetForStringIndex(line, range.upperBound - 1, nil)
needDrawLeadingPointer = true
needDrawTrailingPointer = false
leadingSelectionOffset = leadingOffset
trailingSelectionOffset = trailingOffset
selectionRect.origin = CGPoint(x: lineOriginX, y: rect.height - lineOriginY)
} else if ((range.lowerBound + 1)...range.upperBound).contains(trailingSelectionIndex) {
let leadingOffset = CTLineGetOffsetForStringIndex(line, range.lowerBound, nil)
let trailingOffset = CTLineGetOffsetForStringIndex(line, trailingSelectionIndex, nil)
needDrawLeadingPointer = false
needDrawTrailingPointer = true
leadingSelectionOffset = leadingOffset
trailingSelectionOffset = trailingOffset
selectionRect.size = CGSize(
width: CTLineGetOffsetForStringIndex(line, range.upperBound - 1, nil) - selectionRect
.origin.x,
height: abs((rect.height - lineOriginY) - selectionRect.origin.y + lineHeight)
)
} else if leadingSelectionIndex < range.lowerBound,
trailingSelectionIndex >= range.lowerBound {
let leadingOffset = CTLineGetOffsetForStringIndex(line, range.lowerBound, nil)
let trailingOffset = CTLineGetOffsetForStringIndex(line, range.upperBound - 1, nil)
needDrawLeadingPointer = false
needDrawTrailingPointer = false
leadingSelectionOffset = leadingOffset
trailingSelectionOffset = trailingOffset
} else {
leadingSelectionOffset = nil
trailingSelectionOffset = nil
needDrawLeadingPointer = false
needDrawTrailingPointer = false
}
guard let leadingSelectionOffset = leadingSelectionOffset,
let trailingSelectionOffset = trailingSelectionOffset else {
return
}
let leftmostTextSelectionPoint = CGPoint(
x: rect.origin.x + lineOriginX + leadingSelectionOffset,
y: rect.origin.y + lineOriginY - lineHeight
)
context?.setFillColor(selectionColor.cgColor)
context?.fill(CGRect(
origin: leftmostTextSelectionPoint,
size: CGSize(
width: trailingSelectionOffset - leadingSelectionOffset,
height: lineHeight
)
))
context?.setFillColor(selectionPointerColor.cgColor)
if needDrawLeadingPointer {
drawLeadingPointer(
context: context,
leftmostTextSelectionPoint: leftmostTextSelectionPoint,
lineHeight: lineHeight
)
}
if needDrawTrailingPointer {
drawTrailingPointer(
context: context,
rightmostTextSelectionPoint: leftmostTextSelectionPoint
.movingX(by: -leadingSelectionOffset + trailingSelectionOffset),
lineHeight: lineHeight
)
}
}
private func drawTrailingPointer(
context: CGContext?,
rightmostTextSelectionPoint: CGPoint,
lineHeight: CGFloat
) {
context?.fill(CGRect(
origin: rightmostTextSelectionPoint,
size: CGSize(
width: pointerShapeWidth,
height: lineHeight
)
))
context?.fillEllipse(in: CGRect(
origin: rightmostTextSelectionPoint
.movingX(by: -pointerCircleSize.width / 2 + pointerShapeWidth / 2),
size: pointerCircleSize
))
}
private func drawLeadingPointer(
context: CGContext?,
leftmostTextSelectionPoint: CGPoint,
lineHeight: CGFloat
) {
context?.fill(CGRect(
origin: leftmostTextSelectionPoint.movingX(by: -pointerShapeWidth),
size: CGSize(
width: pointerShapeWidth,
height: lineHeight
)
))
context?.fillEllipse(in: CGRect(
origin: leftmostTextSelectionPoint
.movingX(by: -pointerCircleSize.width / 2 - pointerShapeWidth / 2).movingY(by: lineHeight),
size: pointerCircleSize
))
}
private func horizontalOffset(lineWidth: CGFloat, maxWidth: CGFloat) -> CGFloat {
switch textAlignment {
case .left, .natural, .justified:
return 0
case .center:
return max(((maxWidth - lineWidth) / 2).roundedToScreenScale, 0)
case .right:
return max(maxWidth - lineWidth, 0)
@unknown default:
return 0
}
}
private func subrangeEndsWithSoftHyphen(_ range: NSRange) -> Bool {
let index = string.utf16.index(
string.utf16.startIndex,
offsetBy: range.location + range.length - 1
)
return string.utf16[index] == softHyphen
}
private func trailingSymbolsTrimCount(_ range: NSRange) -> Int {
guard range.length > 1 else {
return 0
}
let utf16 = string.utf16
let lastSymbolIndex = utf16.index(
utf16.startIndex,
offsetBy: range.endIndex - 1
)
let lastSymbol = utf16[lastSymbolIndex]
if lastSymbol == lineFeed, range.length > 2 {
let preLastIndex = utf16.index(before: lastSymbolIndex)
return utf16[preLastIndex] == carriageReturn ? 2 : 1
}
return lastSymbol.isWhitespaceOrNewline ? 1 : 0
}
private func makeTruncatedLine(
from range: NSRange,
constrainedTo width: CGFloat,
truncationToken: NSAttributedString?
) -> CTLine? {
guard range.length > 0 else {
return nil
}
let token: NSAttributedString
if lineBreakMode == .byClipping {
token = NSAttributedString()
} else if let truncationToken = truncationToken {
token = truncationToken
} else {
let allAttributes = attributes(at: range.endIndex - 1, effectiveRange: nil)
token = NSAttributedString(string: ellipsis, attributes: allAttributes)
}
let remainingString: NSAttributedString
let lineTerminatesDueToNewlineSymbol = string.utf16.prefix(range.endIndex).last!.isNewline
if lineTerminatesDueToNewlineSymbol {
let line = attributedSubstring(from: range)
if line.truncationType == CTLineTruncationType.end {
remainingString = line + token
} else {
remainingString = line
}
} else {
remainingString = attributedSubstring(
from: NSRange(location: range.location, length: length - range.location)
)
}
let remainingLine = CTLineCreateWithAttributedString(remainingString)
let tokenLine = CTLineCreateWithAttributedString(token)
guard let truncatedLine = CTLineCreateTruncatedLine(
remainingLine,
Double(width),
remainingString.truncationType,
tokenLine
) else {
return nil
}
let overrun = truncatedLine.typographicBounds.width - width
if overrun.isApproximatelyGreaterThan(0) {
return CTLineCreateTruncatedLine(
remainingLine,
Double(width - overrun),
remainingString.truncationType,
tokenLine
)
} else {
return truncatedLine
}
}
private var lineBreakMode: LineBreakMode {
guard length > 0 else {
return .byWordWrapping
}
let paragraphStyle = attribute(
.paragraphStyle,
at: length - 1,
effectiveRange: nil
) as? NSParagraphStyle
return paragraphStyle?.lineBreakMode ?? .byWordWrapping
}
private var truncationType: CTLineTruncationType {
switch lineBreakMode {
case .byTruncatingHead:
return .start
case .byTruncatingMiddle:
return .middle
default:
return .end
}
}
private var singleLineBreakMode: Bool {
switch lineBreakMode {
case .byTruncatingHead, .byTruncatingMiddle:
return true
case .byClipping, .byCharWrapping, .byWordWrapping, .byTruncatingTail:
return false
@unknown default:
return false
}
}
private var textAlignment: TextAlignment {
let paragraphStyle = attribute(
.paragraphStyle,
at: 0,
effectiveRange: nil
) as? NSParagraphStyle
return paragraphStyle?.alignment ?? .natural
}
func makeCopyWithoutSoftHyphens() -> NSAttributedString {
let copy = mutableCopy() as! NSMutableAttributedString
let hyphenOffsets = copy.string.utf16
.enumerated()
.filter { $0.element == softHyphen }
.map { $0.offset }
var deleteCount = 0
for offset in hyphenOffsets {
copy.deleteCharacters(in: NSRange(location: offset - deleteCount, length: 1))
deleteCount += 1
}
return copy
}
}
extension CTTypesetter {
fileprivate func lineLength(from offset: Int, constrainedTo width: CGFloat) -> CFIndex {
CTTypesetterSuggestLineBreak(self, offset, Double(width))
}
fileprivate func lineLengthByWordBoundary(
for string: String,
from offset: Int,
constrainedTo width: CGFloat
) -> Int {
let lineLength = self.lineLength(from: offset, constrainedTo: width)
if lineLength + offset == string.utf16.count {
return lineLength
}
if string.utf16.prefix(offset + lineLength).last?.isWhitespaceOrNewline == true {
return lineLength
}
let whitespaceOffsetFromEnd = string.utf16
.dropFirst(offset)
.prefix(lineLength)
.reversed()
.enumerated()
.first(where: { _, unit in unit.isWhitespace })?.offset
if let whitespaceOffsetFromEnd = whitespaceOffsetFromEnd {
return lineLength - whitespaceOffsetFromEnd - 1
}
return 0
}
}
extension CTLine {
fileprivate var runs: [CTRun] {
CTLineGetGlyphRuns(self) as! [CTRun]
}
private var typographicBoundsNotConsideringEmojiHeight: TypographicBounds {
let runsBounds: [TypographicBounds] = runs.map {
let bounds = $0.typographicBounds
let font = $0.font
if font.fontName != Font.emojiFontName {
return bounds
}
let systemFont = Font.systemFont(ofSize: font.pointSize)
return modified(bounds) {
$0.ascent = systemFont.ascender
$0.descent = abs(systemFont.descender)
}
}
guard !runsBounds.isEmpty else {
return .default
}
return TypographicBounds(
ascent: runsBounds.map { $0.ascent }.max()!,
descent: runsBounds.map { $0.descent }.max()!,
width: runsBounds.map { $0.width }.reduce(0, +)
)
}
fileprivate var typographicBounds: TypographicBounds {
var bounds = typographicBoundsNotConsideringEmojiHeight
let height = bounds.height
let (minHeight, maxHeight) = overriddenHeight
let offset = max((minHeight - height) / 2, 0) + min((maxHeight - height) / 2, 0)
bounds.ascent += offset
bounds.descent += offset
let descentAddition = 1 - modf(bounds.descent).1
if descentAddition < 0.5 {
bounds.ascent -= descentAddition
bounds.descent += descentAddition
}
return bounds
}
private var overriddenHeight: (min: CGFloat, max: CGFloat) {
var minHeight: CGFloat = 0
var maxHeight: CGFloat = 0
for run in runs {
let style = run.paragraphStyle
minHeight = max(minHeight, style?.minimumLineHeight ?? 0)
maxHeight = max(maxHeight, style?.maximumLineHeight ?? 0)
}
if maxHeight == 0 {
maxHeight = .greatestFiniteMagnitude
}
return (minHeight, maxHeight)
}
fileprivate func draw<ActionType>(
at position: CGPoint,
in context: CGContext,
layoutY: CGFloat,
actionKey: NSAttributedString.Key?
) -> [AttributedStringLayout<ActionType>.Run] {
var runsWithActions = [AttributedStringLayout<ActionType>.Run]()
for run in runs {
let runPosition = CGPoint(x: position.x + run.origin.x, y: position.y)
if let action = (actionKey.flatMap(run.action) as ActionType?) {
let bounds = run.typographicBounds
runsWithActions.append(
AttributedStringLayout<ActionType>.Run(
rect: CGRect(
x: runPosition.x,
y: layoutY,
width: bounds.width,
height: bounds.height
),
action: action
)
)
}
context.textPosition = position
context.inSeparateGState {
context.performDrawing(shadedWith: run.shadow) {
CTRunDraw(run, context, .infinite)
if #available(iOS 15, *) {} else if run.isSingleStrikethrough {
drawStrikethrough(for: run, at: runPosition, in: context)
}
}
}
if let attachment = run.attachment,
let image = attachment.image {
context.saveGState()
let imagePosition = position + run.origin + attachment.bounds.origin
let positionTransform = CGAffineTransform(
translationX: imagePosition.x,
y: imagePosition.y
)
let transform = image.orientationTransform.concatenating(positionTransform)
context.concatenate(transform)
context.draw(image.cgImg!, in: CGRect(origin: .zero, size: attachment.bounds.size))
context.restoreGState()
}
}
return runsWithActions
}
private func drawStrikethrough(for run: CTRun, at position: CGPoint, in context: CGContext) {
context.saveGState()
context.setStrokeColor(run.color)
context.setLineWidth(run.font.estimatedStrikethroughWidth)
context.addPath(run.strikethroughLine(forTextPosition: position))
context.strokePath()
context.restoreGState()
}
}
extension CTRun {
private func attribute<T>(withName name: Any) -> T? {
let runAttributes = CTRunGetAttributes(self) as NSDictionary
return runAttributes[name].flatMap { $0 as? T }
}
private func attribute<T>(withName name: NSAttributedString.Key) -> T? {
attribute(withName: name as Any)
}
fileprivate var typographicBounds: TypographicBounds {
var ascent: CGFloat = 0
var descent: CGFloat = 0
let width = CGFloat(CTRunGetTypographicBounds(self, .infinite, &ascent, &descent, nil))
return TypographicBounds(ascent: ascent, descent: descent, width: width)
}
private var baselineOffset: CGFloat {
var offset: CGFloat? = attribute(withName: .baselineOffset)
if #available(iOS 11, tvOS 11, OSX 10.13, *) {
offset = offset ?? attribute(withName: kCTBaselineOffsetAttributeName)
}
return offset ?? 0
}
fileprivate var paragraphStyle: NSParagraphStyle? {
let style: NSParagraphStyle? = attribute(withName: .paragraphStyle)
?? attribute(withName: kCTParagraphStyleAttributeName)
return style
}
fileprivate var isSingleStrikethrough: Bool {
let underlineStyle: Int? = attribute(withName: .strikethroughStyle)
return underlineStyle == UnderlineStyle.single.rawValue
}
fileprivate var color: CGColor {
let color: Any? = attribute(withName: .foregroundColor) ??
attribute(withName: kCTForegroundColorAttributeName)
if let systemColor = color as? SystemColor {
return systemColor.cgColor
} else if let cgColor = safeCFCast(color as CFTypeRef) as CGColor? {
return cgColor
}
return Color.black.cgColor
}
fileprivate var font: Font {
let font: Font? = attribute(withName: .font) ?? attribute(withName: kCTFontAttributeName)
return font ?? Font.systemFontWithDefaultSize()
}
fileprivate var attachment: TextAttachment? {
let attachment: TextAttachment? = attribute(withName: .attachment)
return attachment
}
fileprivate var origin: CGPoint {
var origins = [CGPoint.zero]
CTRunGetPositions(self, CFRangeMake(0, 1), &origins)
return origins.first!
}
fileprivate func strikethroughLine(forTextPosition position: CGPoint) -> CGPath {
let offset = baselineOffset + ceil(font.xHeight * 0.5)
let width = CTRunGetTypographicBounds(self, .infinite, nil, nil, nil)
let start = CGPoint(
x: position.x,
y: position.y + offset
)
let end = CGPoint(
x: position.x + CGFloat(width),
y: position.y + offset
)
let path = CGMutablePath()
path.move(to: start)
path.addLine(to: end)
return path
}
fileprivate var shadow: SystemShadow? {
attribute(withName: .shadow)
}
fileprivate func action<ActionType>(for key: NSAttributedString.Key) -> ActionType? {
attribute(withName: key) as ActionType?
}
}
extension NSMutableAttributedString {
fileprivate func appendWithPreservedAttributes(_ string: String) {
replaceCharacters(in: NSRange(location: length, length: 0), with: string)
}
}
extension CFRange {
fileprivate static let infinite = CFRange(location: 0, length: 0)
fileprivate static func withLength(_ length: Int) -> CFRange {
CFRange(location: 0, length: length)
}
fileprivate var nsRange: NSRange {
NSRange(location: self.location, length: self.length)
}
}
extension NSRange {
fileprivate var endIndex: Int {
location + length
}
}
extension UTF16Char {
fileprivate var isWhitespace: Bool {
isIn(.whitespaces)
}
fileprivate var isWhitespaceOrNewline: Bool {
isIn(.whitespacesAndNewlines)
}
fileprivate var isNewline: Bool {
isIn(.newlines)
}
private func isIn(_ characterSet: CharacterSet) -> Bool {
UnicodeScalar(self).map(characterSet.contains) ?? false
}
}
public func +(lhs: NSAttributedString, rhs: NSAttributedString) -> NSAttributedString {
let result = lhs.mutableCopy() as! NSMutableAttributedString
result.append(rhs)
return result
}
extension Array {
fileprivate mutating func removeLastIfExists() {
guard !isEmpty else {
return
}
removeLast()
}
}
extension NSAttributedString.VerticalPosition {
fileprivate func verticalOffset(forHeight height: CGFloat, availableHeight: CGFloat) -> CGFloat {
switch self {
case .top: return 0
case .center: return (availableHeight - height) / 2
case .bottom: return availableHeight - height
}
}
}
extension CGContext {
fileprivate func performDrawing(shadedWith shadow: SystemShadow?, _ drawing: () -> Void) {
guard let shadow = shadow, let color = shadow.cgColor else {
drawing()
return
}
// fix for UIKit compatibility
let offsetToUse: CGSize
if #available(iOS 14, *) {
offsetToUse = shadow.shadowOffset
} else {
offsetToUse = CGSize(
width: shadow.shadowOffset.width,
height: -shadow.shadowOffset.height
)
}
setShadow(
offset: offsetToUse,
blur: shadow.shadowBlurRadius,
color: color
)
drawing()
}
}
extension Font {
fileprivate var estimatedStrikethroughWidth: CGFloat {
// count strikethrough line of 1 pt as normal for default 12-pt font size
max(
1 / PlatformDescription.screenScale(),
(pointSize / 12).roundedToScreenScale
)
}
}
private let selectionColor = Color.colorWithHexCode(0xB3_D7_FE_7F)
private let selectionPointerColor = Color.colorWithHexCode(0x22_66_C5_FF)
private let pointerCircleSize = CGSize(width: 10, height: 10)
private let pointerShapeWidth: CGFloat = 2