Accessing NSTextView metrics - swift

I have an NSTextVIew in which I am only showing mono-spaced characters from the standard alphabet. So no numbers, special characters, emojis, etc. Each character equals one glyph. On top of the text I need to draw some shapes, and I am looking for a way to access some metrics from the text system:
the distance from one character to the next one
the distance from one line to the next one
See the picture for what I mean.
There don't seem to be any properties that I can use directly, or at least I haven't found them, so I am now use the text view's layoutManager to obtain these values:
For the first one I obtain the enclosing rects for two adjacent characters via the boundingRect(forGlyphRange glyphRange: NSRange, in container: NSTextContainer) -> NSRect method of the layoutmanager, and subtract the origin.x for both rects.
For the second one, I could use the same function, but then I need to know the range for the first character on the second line. Or iterate over all the characters and once the origin.y of the enclosing rect changes, I have the first character on the second line and I can calculate the distance between two lines.
EDIT : here's possible code using the layoutManager:
typealias TextMetrics = (distanceBetweenCharacters: CGFloat, distanceBetweenLines: CGFloat)
var metrics: TextMetrics = self.textMetrics() // need to update when text changes
func textMetrics() -> TextMetrics {
guard let lm = self.layoutManager,
let tc = self.textContainer
else { return (0,0)
}
var distanceBetweenCharacters: CGFloat = 0.0
var distanceBetweenLines: CGFloat = 0.0
if string.count > 2 {
let firstRect = lm.boundingRect(forGlyphRange: NSRange(location: 0, length: 1), in: tc)
let secondRect = lm.boundingRect(forGlyphRange: NSRange(location: 1, length: 1), in: tc)
distanceBetweenCharacters = secondRect.maxX - firstRect.maxX
for (index, _) in string.enumerated() {
let rect = lm.boundingRect(forGlyphRange: NSRange(location: index, length: 1), in: tc)
if rect.maxY > firstRect.maxY { // reached next line
distanceBetweenLines = rect.maxY - firstRect.maxY
break
}
}
}
return (distanceBetweenCharacters, distanceBetweenLines)
}
I also looked at getting these from the defaultParagraphStyle, but if I access that, it is nil.
Is there maybe another, easier way to obtain these values?

After some more searching and trial and error, I found that distanceBetweenLines could be calculated from the font metrics and the lineHeightMultiple, which is a property from NSParagraphStyle, but it could also be defined outside the paragraph style, which is what I do.
So in the end this works:
let distanceBetweenLines = layoutManager.defaultLineHeight(for: myTextFont) * lineHeightMultiple
For distanceBetweenCharacters, I have not found another solution.
EDIT
Based on the suggestion from #Willeke in the comment below, I now calculate distanceBetweenCharacters as follows:
let distanceBetweenCharacters = myTextFont.advancement(forCGGlyph: layoutManager.cgGlyph(at: 0)).width + myKerning

Related

How to configure axes and set explicit axis start and end points on a line chart in iOS 16.0's Swift Charts

In this code I declare a Chart with an x scale domain between two doubles:
Chart(points, id: \.self) { point in
LineMark(
x: .value("time/s", point.timestamp),
y: .value("potential/mV", point.potential)
)
.foregroundStyle(by: .value("Electrode", point.electrode.symbol))
}
.chartXScale(domain: timeWindow)
// ...
where timeWindow is declared as follows:
var timeWindow: ClosedRange<Double> {
doc.contents.time(at: window.lowerBound)...doc.contents.time(at: window.upperBound)
}
and time is defined as:
func time(at index: Int) -> Double {
return Double(index) * (1 / sampleRate)
}
However, the chart x-axis this produces does not have a start and end at the lower and upper bounds of the ClosedRange<Double> I have provided, rather there is a section of mystery space on either the left and right side of the plots:
How can I get the chart to adhere to the domain I have provided (the bounds are also displayed in the text view of the bottomBar ToolbarItemGroup, using the code Text("\(timeWindow.lowerBound.format()) s to \(timeWindow.upperBound.format()) s"))? And how can I get the y-axis to render on the left of the chart?
The solution is to create an array of values, enabling the construction of axis content by using AxisMarks:
var xValues: [Double] {
stride(from: timeWindow.lowerBound, to: timeWindow.upperBound, by: (timeWindow.upperBound - timeWindow.lowerBound) / 5).map { $0 }
}
And then in the chart modifiers:
Chart(points, id: \.self) { point in
LineMark(
x: .value("time/s", point.timestamp),
y: .value("potential/mV", point.potential)
)
.foregroundStyle(by: .value("Electrode", point.electrode.symbol))
}
.chartXScale(domain: ClosedRange(uncheckedBounds: (lower: timeWindow.lowerBound, upper: timeWindow.upperBound)))
.chartXAxis {
AxisMarks(values: xValues)
}
\\ ...
N.B.: I have found that it is still necessary to include the chartXScale(domain:) modifier, as otherwise the x-axis seems to always begin at zero, even if that is not included in the axis marks.
This produces the intended behaviour for the 'scrollable' plot:
However, there is a drawback of now losing the automatic method of determining mark spacing in the axis, but this is a compromise I will have to deal with, and construct a better algorithm for creating them instead of hard coded values like the 5 used above.

Get the NSRange of last line inside one line

How can I get the NSRange of last line inside one line? (If cursor is at the end of it)
In the image example, I want to get the line range of the text in red box.
My code now get the range of whole line (green box):
var currentLineRange: NSRange {
let nsText = self.text as NSString
let currentLineRange = nsText.lineRange(for: self.selectedRange)
return currentLineRange
}
Notice that the range you want is a function of the width with which you have laid out the text, and the font of the text, among many other things. You cannot tell this from a String alone, as a String is just some Characters, and does not have those information. The lineRange method looks for new line characters in the string, which there evidently aren't, in your Lorem Ipsum paragraph, between "of" and "Lorem", hence that is not counted as a "line".
Assuming this is displayed in a UITextView (as that is what you have tagged the question with), you can use its layoutManager to get the range you want.
let selectedRange = textview.selectedRange
let glyphRange = textview.layoutManager.glyphRange(forCharacterRange: selectedRange, actualCharacterRange: nil)
// if the selection starts after the last glyph, glyphRange.lowerBound is not a valid glyph index
// so we need to use the last glyph in that case
let glyphIndex = glyphRange.lowerBound == textview.layoutManager.numberOfGlyphs ?
glyphRange.lowerBound - 1 : glyphRange.lowerBound
var effectiveGlyphRange = NSRange(location: 0, length: 0)
// this gets the *glyph* range of the line
textview.layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &effectiveGlyphRange)
let effectiveCharRange = textview.layoutManager.characterRange(forGlyphRange: effectiveGlyphRange, actualGlyphRange: nil)
This code will get the range of the line (aka line fragment) containing the start of the selected range.
If you want to get a range of multiple lines when multiple lines are selected (similar to how lineRange would have behaved), it should be trivial to modify the code to also get the range of the line containing the end of the selected range, then combine the two ranges.

Get the range of a paragraph NSAttributedString

I would like to be able to get the range(s) of the paragraph(s) overlapping with the textView.selectedRange.
My goal is to change the alignment of the whole paragraph where the selection is.
I have tried the following but the ranges provided by enumerate attribute seem to all be of length 1, making my intersection check useless.
Is there a way to get a list of continuous ranges with the same paragraph style?
var alignment: NSTextAlignment
let contentRange = NSRange(location: 0, length: editor.contentLength)
editor.attributedText.enumerateAttribute(.paragraphStyle, in: contentRange, options: .longestEffectiveRangeNotRequired) { (paragraphStyle, range, _) in
//Check if attribute range contains cursor
if NSIntersectionRange(range, editor.selectedRange).length > 0 {
print(range)
if let paragraphStyle = paragraphStyle as? NSParagraphStyle {
let newStyle: NSMutableParagraphStyle = paragraphStyle.mutableParagraphStyle
newStyle.alignment = alignment
editor.addAttribute(.paragraphStyle, value: newStyle, at: range)
}
}
}
Many thanks
Edit
Thanks to #Larme, having no options does indeed get a continuous range:
editor.attributedText.enumerateAttribute(.paragraphStyle, in: contentRange, options: []) { (paragraphStyle, range, _) in
However, this will combine consecutive paragraphs with the same paragraph style in the same range.
i.e.
The user creates three paragraphs aligned to the left
The user wants to change the middle one to align right
They should be able to
a) select any portion of the paragraph
b) have the cursor at any position in the paragraph (selection length 0) to achieve the same result (affecting the whole paragraph)
The current check using enumarateAttribute will return a range grouping all three paragraphs together (as they all have the same paragraphStyle) and apply the new alignment to all of them.
The enumerateAttribute doesn't really get the range of a single paragraph it will return a consecutive range for all consecutive paragraphs with the same paragraphStyle
Is there another way to get the range of the paragraph corresponding to the selectedRange?
A bit late to the party, but hopefully this helps.
The way I approached this problem was by saving the selected range, pulling an NSString from my NSAttributedString, then calling NSString's paragraphRange. Once you have the paragraph range, then you can enumerate however you'd like!
//0: SAVE RANGE
let rangeOfNote = yourTextView.selectedRange
//1: CONVERT NSATTRIBUTEDSTRING TO NSSTRING
let composeText = yourTextView.attributedText.string as NSString
//2: CALL NSSTRING'S PARAGRAPH RANGE (for: NSRange)
let paragraphRange = composeText.paragraphRange(for: rangeOfNote)
//3: CHECK CORRECT
print(composeText.substring(with: paragraphRange), paragraphRange)
//4: Use range in enumerate attributes.
yourTextView.attributedText.enumerateAttributes(in: paragraphRange, ...)

Why doesn't vDSP_maxv find the max value in the vector?

vDSP_maxv is not assigning the max value to output in the code below.
I expected the last line to print 2, but instead it prints something different each time, usually a very large or small number like 2.8026e-45
I've read this tutorial, the documentation, and the inline documentation in the header file for vDSP_maxv, but I don't see why the code below isn't producing the expected result.
Making numbers an UnsafePointer instead of an UnsafeMutablePointer didn't work, nor did a number of other things I've tried, so maybe I'm missing something fundamental.
import Accelerate
do {
// INPUT - pointer pointing at: 0.0, 1.0, 2.0
let count = 3
let numbers = UnsafeMutablePointer<Float>
.allocate(capacity: count)
defer { numbers.deinitialize() }
for i in 0..<count {
(numbers+i).initialize(to: Float(i))
}
// OUTPUT
var output = UnsafeMutablePointer<Float>
.allocate(capacity: 1)
// FIND MAX
vDSP_maxv(
numbers,
MemoryLayout<Float>.stride,
output,
vDSP_Length(count)
)
print(output.pointee) // prints various numbers, none of which are expected
}
You are mistaking the usage of the stride parameter to vDSP_maxv.
You need to pass the number of elements consisting a single stride, not the number of bytes.
vDSP_maxv(::::)
*C = -INFINITY;
for (n = 0; n < N; ++n)
if (*C < A[n*I])
*C = A[n*I];
In the pseudo code above, I represents the stride parameter, and you see giving 4 (MemoryLayout<Float>.stride) to I would generate indexes exceeding the bound of A (your numbers).
Some other parts fixed to fit my preference, but the most important thing is the second parameter for vDSP_maxv:
import Accelerate
do {
// INPUT - pointer pointing at: 0.0, 1.0, 2.0
let numbers: [Float] = [0.0, 1.0, 2.0]
// OUTPUT
var output: Float = Float.nan
// FIND MAX
vDSP_maxv(
numbers,
1, //<- when you want to use all elements in `numbers` continuously, you need to pass `1`
&output,
vDSP_Length(numbers.count)
)
print(output) //-> 2.0
}

NSAttributedString and emojis: issue with positions and lengths

I'm coloring some parts of a text coming from an API (think "#mention" as on Twitter) using NSAttributedString.
The API gives me the text and an array of entities representing the parts of the text that are mentions (or links, tags, etc) which should be colored.
But sometimes, the coloration is offset because of emojis.
For example, with this text:
"#ericd Some text. #apero"
the API gives:
[
{
"text" : "ericd",
"len" : 6,
"pos" : 0
},
{
"text" : "apero",
"len" : 6,
"pos" : 18
}
]
which I successfully translate to an NSAttributedString using NSRange:
for m in entities.mentions {
let r = NSMakeRange(m.pos, m.len)
myAttributedString.addAttribute(NSForegroundColorAttributeName, value: someValue, range: r)
}
We see that "pos": 18 is correct, this is where "#apero" starts. The colored parts are "#ericd" and "#apero", as expected.
but when some specific combinations of emojis are used in the text, the API does not translate well to NSATtributedString, the coloration is offset:
"#ericd Some text. 😺✌🏻 #apero"
gives:
[
{
"text" : "ericd",
"len" : 6,
"pos" : 0
},
{
"text" : "apero",
"len" : 6,
"pos" : 22
}
]
"pos": 22: the API author states that this is correct, and I understand their point of view.
Unfortunately, NSAttributedString does not agree, my coloration is off:
The last characters for the second mention are not colored (because the "pos" is too short because of the emojis?).
As you might have already guessed, I cannot in any way change the way the API behaves, I have to adapt on client side.
Except that... I have no idea what to do. Should I try to detect what kind of emojis are in the text and manually amend the position of mentions when there's a problematic emoji? But what would be the criteria to detect which emoji shifts the position and which doesn't? And how to decide how much offset I need? Maybe the problem is caused by NSAttributedString?
I understand that this is related to the emojis length once composed compared to their length as discrete characters, but... well... I'm lost (sigh).
Note that I've tried to implement a solution similar to this stuff because my API is compatible with this one, but it only worked partially, some emojis were still breaking the indexes:
A Swift String provides different "views" on its contents.
A good overview is given in "Strings in Swift 2" in the Swift Blog:
characters is a collection of Character values, or extended grapheme clusters.
unicodeScalars is a collection of Unicode scalar values.
utf8 is a collection of UTF–8 code units.
utf16 is a collection of UTF–16 code units.
As it turned out in the discussion, pos and len from your API
are indices into the Unicode scalars view.
On the other hand, the addAttribute() method of NSMutableAttributedString takes an NSRange, i.e. the range corresponding
to indices of the UTF-16 code points in an NSString.
String provides methods to "translate" between indices of the
different views (compare NSRange to Range<String.Index>):
let text = "#ericd Some text. 😺✌🏻 #apero"
let pos = 22
let len = 6
// Compute String.UnicodeScalarView indices for first and last position:
let from32 = text.unicodeScalars.index(text.unicodeScalars.startIndex, offsetBy: pos)
let to32 = text.unicodeScalars.index(from32, offsetBy: len)
// Convert to String.UTF16View indices:
let from16 = from32.samePosition(in: text.utf16)
let to16 = to32.samePosition(in: text.utf16)
// Convert to NSRange by computing the integer distances:
let nsRange = NSRange(location: text.utf16.distance(from: text.utf16.startIndex, to: from16),
length: text.utf16.distance(from: from16, to: to16))
This NSRange is what you need for the attributed string:
let attrString = NSMutableAttributedString(string: text)
attrString.addAttribute(NSForegroundColorAttributeName,
value: UIColor.red,
range: nsRange)
Update for Swift 4 (Xcode 9): In Swift 4, the standard library
provides methods to convert between Swift String ranges and NSString
ranges, therefore the calculations simplify to
let text = "#ericd Some text. 😺✌🏻 #apero"
let pos = 22
let len = 6
// Compute String.UnicodeScalarView indices for first and last position:
let fromIdx = text.unicodeScalars.index(text.unicodeScalars.startIndex, offsetBy: pos)
let toIdx = text.unicodeScalars.index(fromIdx, offsetBy: len)
// Compute corresponding NSRange:
let nsRange = NSRange(fromIdx..<toIdx, in: text)