Saving and pasting an attributed string with a custom NSTextBlock - swift

I am trying to create a custom NSTextBlock, much like the one Apple did at WWDC 18 (23 mins in).
Full demo project here.
Okay, so it works great when I'm editing and marking a paragraph with my paragraph style that has the text block attached.
But when I cut and paste it (or archive/unarchive from disk), it loses it. EDIT: It actually turns my TweetTextBlock subclass into a NSTableViewTextBlock, which also explains the borders.
Implementation
Here's a full Xcode project. Use the Format top menu item to trigger the markTweet function.
Here's how I add the attributes to the paragraph
#IBAction func markTweet(_ sender : Any?){
print("now we are marking")
let location = textView.selectedRange().location
guard let nsRange = textView.string.extractRange(by: .byParagraphs, at: location) else { print("Not in a paragraph"); return }
let substring = (textView.string as NSString).substring(with: nsRange)
let tweetParagraph = NSMutableParagraphStyle()
tweetParagraph.textBlocks = [TweetTextBlock()]
let twitterAttributes : [AttKey : Any] = [
AttKey.paragraphStyle : tweetParagraph,
AttKey.font : NSFont(name: "HelveticaNeue", size: 15)
]
textView.textStorage?.addAttributes(twitterAttributes, range: nsRange)
}
And this is my NSTextBlock subclass
import Cocoa
class TweetTextBlock: NSTextBlock {
override init() {
super.init()
setWidth(33.0, type: .absoluteValueType, for: .padding)
setWidth(70.0, type: .absoluteValueType, for: .padding, edge: .minX)
setValue(100, type: .absoluteValueType, for: .minimumHeight)
setValue(300, type: .absoluteValueType, for: .width)
setValue(590, type: .absoluteValueType, for: .maximumWidth)
backgroundColor = NSColor(white: 0.97, alpha: 1.0)
}
override func drawBackground(withFrame frameRect: NSRect, in controlView: NSView,
characterRange charRange: NSRange, layoutManager: NSLayoutManager) {
let frame = frameRect
let fo = frameRect.origin
super.drawBackground(withFrame: frame, in: controlView, characterRange:
charRange, layoutManager: layoutManager)
// draw string
let context = NSGraphicsContext.current
context?.shouldAntialias = true
let drawPoint: NSPoint = CGPoint(x: fo.x + 70, y: fo.y + 10)
let nameAttributes = [AttKey.font: NSFont(name: "HelveticaNeue-Bold", size: 15), .foregroundColor: NSColor.black]
var handleAttributes = [AttKey.font: NSFont(name: "HelveticaNeue", size: 15), .foregroundColor: NSColor(red: 0.3936756253, green: 0.4656872749, blue: 0.5323709249, alpha: 1)]
let nameAStr = NSMutableAttributedString(string: "Johanna Appleseed", attributes: nameAttributes)
let handleAStr = NSAttributedString(string: " #johappleseed ยท 3h", attributes: handleAttributes)
nameAStr.append(handleAStr)
nameAStr.draw(at: drawPoint)
let im = NSImage(named: "profile-twitter")!
im.draw(in: NSRect(x: fo.x + 10, y: fo.y + 10, width: 50, height: 50))
}
required init?(coder: NSCoder) {
super.init(coder: coder)
}
}
What I tried
My thinking is that this might happen because TextKit doesn't know how to archive the attributes from the custom block. But I tried overriding init:fromCoder and encode. They don't get called. Not on copy, paste, archiving, unarchiving. So I suppose that was not it. This leads me to think that all this custom drawing logic can't be saved in an attributed string, and that this is all happening in the layout manager. That makes sense. But how do I persist the block, then?
UPDATE: I tried reading the attributes. It has a paragraph style, and that paragraph style has an item in the textBlocks array property. But that text block is an NSTextBlock and not my subclass (i tried if block is TweetTextBlock which returns false)
UPDATE 2: I tried overriding properties like classForArchiver, and then reading them with e.g. print("twb: Class for archiver", block.classForArchiver). What's interesting here is that the text block has been turned into a NSTextTableBlock! I'm so deep in hacking this now that I'm looking for a way to store the className somewhere in the text block. So far, the only one I can think of is the tooltip property, but that's visible to the user, and I might want to use that for something else.
UPDATE 3: The tooltip is also not preserved. That's weird. The next big hack I can think of is setting the text color to HSB (n, 0, 0), where n is the identifier for the NSTextBlock subclass. Let's hope I don't have to go there.
UPDATE 4. This is most likely caused by both archiving and copy/pasting transforms the string into RTF. Here's public.rtf from my clipboard
{\rtf1\ansi\ansicpg1252\cocoartf2509
\cocoatextscaling0\cocoaplatform0{\fonttbl\f0\fnil\fcharset0 HelveticaNeue;}
{\colortbl;\red255\green255\blue255;\red245\green245\blue245;}
{\*\expandedcolortbl;;\csgray\c97000;}
\pard\intbl\itap1\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0
\f0\fs30 \cf0 THIS text is in a TweetTextBlock}

It appears the NSAttributedString is somehow at fault. I tried subclassing NSMutableParagraphStyle and using it and it is NOT being encoded or decoded (init).
It may be possible to simply annotate the text run with a custom Attribute.Key indicating the delineation of the block content and its "type" and then post-process the AttributedString after the paste.
Alternatively, the out-of-the-box Pasteboard types may not support and archived NSAttributedString. Rather, (and I'm guessing) the highest fidelity text type may be RTF which may account for the fact that the TextBlock NSCoding methods aren't invoked at all.
Looking at NSPasteboard.PasteboardType my vote is option 2.

Related

UITextView style is reset when I change kerning

The style is reset when I change kerning. For example, when I change the text size or color, it is saved, but when I change kerning, the style UITextView is reset
#IBAction func sizeTextEdit(_ sender: Any) {
self.textOne?.font = UIFont.systemFont(ofSize: CGFloat(sizeText.value * 1))
}
#IBAction func kernTextEdit(_ sender: Any) {
let textString = textOne.text
let attrs: [NSAttributedString.Key : Any] = [.kern: kernText.value]
textOne?.attributedText = NSAttributedString(string: textString!, attributes: attrs)
}
As you can see in the first screenshot, I increased the font, then I increased the distance between the letters and the font size was reset
The font property only changes the font for the text property. The attributedText is a different property, so you need to define the font for it too:
let attrs: [NSAttributedString.Key : Any] = [.kern: kernText.value,
.font: UIFont.systemFont(ofSize: CGFloat(sizeText.value * 1))]
textOne?.attributedText = NSAttributedString(string: textString!, attributes: attrs)
On a side note, if you want to do a thing like this it's better to stick to one property, in this case attributedText, otherwise you have to keep them in sync.

How can I get the optical bounds of an NSAttributedString?

I need the optical bounds of an attributed string. I know I can call the .size() method and read its width but this obviously gives me typographic bounds with additional space to the right.
My strings would all be very short and consist only of 1-3 characters, so every string would contain exactly one glyphrun.
I found the function CTRunGetImageBounds, and after following the hints in the link from the comment I was able to extract the run and get the bounds, but obviously this does not give me the desired result.
The following swift 4 code works in an XCode9 Playground:
import Cocoa
import PlaygroundSupport
public func getGlyphWidth(glyph: CGGlyph, font: CTFont) -> CGFloat {
var glyph = glyph
var bBox = CGRect()
CTFontGetBoundingRectsForGlyphs(font, .default, &glyph, &bBox, 1)
return bBox.width
}
class MyView: NSView {
init(inFrame: CGRect) {
super.init(frame: inFrame)
}
required init?(coder decoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func draw(_ rect: CGRect) {
// setup context properties
let context: CGContext = NSGraphicsContext.current!.cgContext
context.setStrokeColor(CGColor.black)
context.setTextDrawingMode(.fill)
// prepare variables and constants
let alphabet = ["A","B","C","D","E","F","G","H","I","J","K","L"]
let font = CTFontCreateWithName("Helvetica" as CFString, 48, nil)
var glyphX: CGFloat = 10
// draw alphabet as single glyphs
for letter in alphabet {
var glyph = CTFontGetGlyphWithName(font, letter as CFString)
var glyphPosition = CGPoint(x: glyphX, y: 80)
CTFontDrawGlyphs(font, &glyph, &glyphPosition, 1, context)
glyphX+=getGlyphWidth(glyph: glyph, font: font)
}
let textStringAttributes: [NSAttributedStringKey : Any] = [
NSAttributedStringKey.font : font,
]
glyphX = 10
// draw alphabet as attributed strings
for letter in alphabet {
let textPosition = NSPoint(x: glyphX, y: 20)
let text = NSAttributedString(string: letter, attributes: textStringAttributes)
let line = CTLineCreateWithAttributedString(text)
let runs = CTLineGetGlyphRuns(line) as! [CTRun]
let width = (CTRunGetImageBounds(runs[0], nil, CFRange(location: 0,length: 0))).maxX
text.draw(at: textPosition)
glyphX += width
}
}
}
var frameRect = CGRect(x: 0, y: 0, width: 400, height: 150)
PlaygroundPage.current.liveView = MyView(inFrame: frameRect)
The code draws the single letters from A - L as single Glyphs in the upper row of the playground's live view. The horizontal position will be advanced after each letter by the letter's width which is retrieved via the getGlyphWidth function.
Then it uses the same letters to create attributed strings from it which will then be used to create first a CTLine, extract the (only) CTRun from it and finally measure its width. The result is seen in the second line in the live view.
The first line is the desired result: The width function returns exactly the width of every single letter, resulting in them touching each other.
I want the same result with the attributed string version, but here the ImageBounds seem to add an additional padding which I want to avoid.
How can I measure the exact width from the leftmost to the rightmost pixel of a given text?
And is there a less clumsy way to achieve this without having to cast four times (NSAtt.Str->CTLine->CTRun->CGRect->maxX) ?
Ok, I found the answer myself:
Using the .width parameter of the CTRunGetImageBounds instead of .maxX brings the right result
The same function also does exist for the CTLine: CTLineGetImageBounds

Style placeholder text NSSearchField and NSTextField?

I've been creating a MacOS app and am having trouble styling the font of an NSSearchField (named searchField). My code so far is as follows:
Declared at top of single main viewController class:
let normalTextStyle = NSFont(name: "PT Mono", size: 14.0)
let backgroundColour = NSColor(calibratedHue: 0.6,
saturation: 0.5,
brightness: 0.2,
alpha: 1.0)
let normalTextColour = NSColor(calibratedHue: 0.5,
saturation: 0.1,
brightness: 0.9,
alpha: 1.0)
Declared in viewDidLoad:
searchField.backgroundColor = backgroundColour
searchField.textColor = normalTextColour
searchField.font = normalTextStyle
searchField.centersPlaceholder = false
searchField.currentEditor()?.font = normalTextStyle
let attrStr = NSMutableAttributedString(string: "Search...",
attributes: [NSForegroundColorAttributeName: normalTextColour])
searchField.placeholderAttributedString = attrStr
Generally this works except in one condition: when the search field has focus but no search term has been entered. In this case the placeholder text has the correct colour but the font seems to return to the default (Helvetica 12 point?). As soon as something is typed in or the field loses focus, then the correct font is used once more.
I have tried with no luck looking through the Apple docs for some kind of font or colour settings not currently being set. I have fiddled about with all the font setting I could find in the interface builder, including cocoa bindings and the normal settings in the inspector.
Do I need to set some value of the currentEditor? I am guessing not because the font is changed once text is entered.. I am stuck - can anyone help?
EDIT: I've now tried with an NSTextField and the results are the same. Does anyone have any ideas?
I eventually managed to find an answer. I created a new class TitleTextFormatter of type Formatter, which is 'is intended for subclassing. A custom formatter can restrict the input and enhance the display of data in novel ways'. All I needed to do was override certain default functions to get what I needed:
import Cocoa
class TitleTextFormatter: Formatter {
override func string(for obj: Any?) -> String? {
/*
* this function receives the object it is attached to.
* in my case it only ever receives an NSConcreteAttributedString
* and returns a plain string to be formatted by other functions
*/
var result: String? = nil
if let attrStr = obj as? NSAttributedString {
result = attrStr.string
}
return result
}
override func getObjectValue( _ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?,
for string: String,
errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
/*
* this function in general is overridden to provide an object created
* from the input string. in this instance, all I want is an attributed string
*/
let titleParagraphStyle = NSMutableParagraphStyle()
titleParagraphStyle.alignment = .center
let titleAttributes = [NSAttributedStringKey.foregroundColor: NSColor.mainText,
NSAttributedStringKey.font: NSFont.titleText,
NSAttributedStringKey.paragraphStyle: titleParagraphStyle]
let titleAttrStr = NSMutableAttributedString(string: string,
attributes: titleAttributes)
obj?.pointee = titleAttrStr
return true
}
override func attributedString(for obj: Any,
withDefaultAttributes attrs: [NSAttributedStringKey : Any]? = nil) -> NSAttributedString? {
/*
* is overridden to show that an attributed string is created from the
* formatted object. this happens to duplicate what the previous function
* does, only because the object I want to create from string is an
* attributed string
*/
var titleAttrStr: NSMutableAttributedString?
if let str = string(for: obj) {
let titleParagraphStyle = NSMutableParagraphStyle()
titleParagraphStyle.alignment = .center
let titleAttributes = [NSAttributedStringKey.foregroundColor: NSColor.mainText,
NSAttributedStringKey.font: NSFont.titleText,
NSAttributedStringKey.paragraphStyle: titleParagraphStyle]
titleAttrStr = NSMutableAttributedString(string: str,
attributes: titleAttributes)
}
return titleAttrStr
}
}
and then in viewDidLoad I added the following:
let titleTextFormatter = TitleTextFormatter()
titleTextField.formatter = titleTextFormatter

tvOS: Anyway to display a subtitle outside of the AVPlayer?

So the scenario is that there is a view where the user can enable/disable subtitles in an app I'm helping to develop.
On that view there is a sample text saying "This is what captions look like", and at the moment it's just a basic, unstyled UILabel. Ideally I would like it to be styled in a similar manner to how the user has customized their captions in the System Settings.
Is this possible in any way? I've envisioned two possible method:
Create an AVPlayer instance and a .vtt file with the text, load it into the view and pause the player. I'm not sure this is possible with a sample video (and it would somehow have to be transparent as there is an image behind the sample sub text).
Somehow get all the styling (font, size, background color, etc) the user has set for their subtitle and create an attributed string to match that
Method 2 seems like the most feasible way, but I don't know if we have access to those settings in code.
So I figured it out! It basically makes use a combination of the Media Accessibility API, which allows you to get the values the user has chosen for their captions/subtitle settings, Attributed Strings, and a subclass UILabel (although this could maybe be substituted with a UITextView as that will allow you to set it's UIEdgeInsets natively)
So, first, the subclass is to allow the UILabel to be inset. This is because captions can have a background color AND a text highlight color and without the inset, the text highlight is all you see. So the function the subclass is simple:
class InsetUILabel: UILabel {
override func drawTextInRect(rect: CGRect) {
let inset: CGFloat = 15
let insets: UIEdgeInsets = UIEdgeInsets(top: inset, left: inset/2, bottom: inset, right: inset/2)
super.drawTextInRect(UIEdgeInsetsInsetRect(rect, insets))
}
}
And for generating the actual label. This uses a label called textSample, but you can obviously make it a little more general.
import MediaAccessibility
func styleLabel(sampleText: String) {
let domain = MACaptionAppearanceDomain.User
// Background styling
let backgroundColor = UIColor(CGColor: MACaptionAppearanceCopyWindowColor(domain, nil).takeRetainedValue())
let backgroundOpacity = MACaptionAppearanceGetWindowOpacity(domain, nil)
textSample.layer.backgroundColor = backgroundColor.colorWithAlphaComponent(backgroundOpacity).CGColor
textSample.layer.cornerRadius = MACaptionAppearanceGetWindowRoundedCornerRadius(domain, nil)
// Text styling
var textAttributes = [String:AnyObject]()
let fontDescriptor = MACaptionAppearanceCopyFontDescriptorForStyle(domain, nil, MACaptionAppearanceFontStyle.Default).takeRetainedValue()
let fontName = CTFontDescriptorCopyAttribute(fontDescriptor, "NSFontNameAttribute") as! String
let fontColor = UIColor(CGColor: MACaptionAppearanceCopyForegroundColor(domain, nil).takeRetainedValue())
let fontOpacity = MACaptionAppearanceGetForegroundOpacity(domain, nil)
let textEdgeStyle = MACaptionAppearanceGetTextEdgeStyle(domain, nil)
let textHighlightColor = UIColor(CGColor: MACaptionAppearanceCopyBackgroundColor(domain, nil).takeRetainedValue())
let textHighlightOpacity = MACaptionAppearanceGetBackgroundOpacity(domain, nil)
let textEdgeShadow = NSShadow()
textEdgeShadow.shadowColor = UIColor.blackColor()
let shortShadowOffset: CGFloat = 1.5
let shadowOffset: CGFloat = 3.5
switch(textEdgeStyle) {
case .None:
textEdgeShadow.shadowColor = UIColor.clearColor()
case .DropShadow:
textEdgeShadow.shadowOffset = CGSize(width: -shortShadowOffset, height: shortShadowOffset)
textEdgeShadow.shadowBlurRadius = 6
case .Raised:
textEdgeShadow.shadowOffset = CGSize(width: 0, height: shadowOffset)
textEdgeShadow.shadowBlurRadius = 5
case .Depressed:
textEdgeShadow.shadowOffset = CGSize(width: 0, height: -shadowOffset)
textEdgeShadow.shadowBlurRadius = 5
case .Uniform:
textEdgeShadow.shadowColor = UIColor.clearColor()
textAttributes[NSStrokeColorAttributeName] = UIColor.blackColor()
textAttributes[NSStrokeWidthAttributeName] = -2.0
default:
break
}
textAttributes[NSFontAttributeName] = UIFont(name: fontName, size: (textSample.font?.pointSize)!)
textAttributes[NSForegroundColorAttributeName] = fontColor.colorWithAlphaComponent(fontOpacity)
textAttributes[NSShadowAttributeName] = textEdgeShadow
textAttributes[NSBackgroundColorAttributeName] = textHighlightColor.colorWithAlphaComponent(textHighlightOpacity)
textSample.attributedText = NSAttributedString(string: sampleText, attributes: textAttributes)
}
Now the text highlight section makes use of shadows, with values I think look pretty good, but you might want to tweak them a tiny bit. Hope this helps!

Color attribute is ignored in NSAttributedString with NSLinkAttributeName

In an NSAttributedString, a range of letters has a link attribute and a custom color attribute.
In Xcode 7 with Swift 2, it works:
In Xcode 8 with Swift 3, the custom attributed color for the link is always ignored (it should be orange in the screenshot).
Here's the code for testing.
Swift 2, Xcode 7:
import Cocoa
import XCPlayground
let text = "Hey #user!"
let attr = NSMutableAttributedString(string: text)
let range = NSRange(location: 4, length: 5)
attr.addAttribute(NSForegroundColorAttributeName, value: NSColor.orangeColor(), range: range)
attr.addAttribute(NSLinkAttributeName, value: "http://somesite.com/", range: range)
let tf = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 50))
tf.allowsEditingTextAttributes = true
tf.selectable = true
tf.stringValue = text
tf.attributedStringValue = attr
XCPlaygroundPage.currentPage.liveView = tf
Swift 3, Xcode 8:
import Cocoa
import PlaygroundSupport
let text = "Hey #user!"
let attr = NSMutableAttributedString(string: text)
let range = NSRange(location: 4, length: 5)
attr.addAttribute(NSForegroundColorAttributeName, value: NSColor.orange, range: range)
attr.addAttribute(NSLinkAttributeName, value: "http://somesite.com/", range: range)
let tf = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 50))
tf.allowsEditingTextAttributes = true
tf.isSelectable = true
tf.stringValue = text
tf.attributedStringValue = attr
PlaygroundPage.current.liveView = tf
I've sent a bug report to Apple, but in the meantime if someone has an idea for a fix or workaround in Xcode 8, that would be great.
Apple Developer has answered:
Please know that our engineering team has determined that this issue behaves as intended based on the information provided.
And they explain why it worked before but doesn't anymore:
Unfortunately, the previous behavior (attributed string ranges with NSLinkAttributeName rendering in a custom color) was not explicitly supported. It happened to work because NSTextField was only rendering the link when the field editor was present; without the field editor, we fall back to the color specified by NSForegroundColorAttributeName.
Version 10.12 updated NSLayoutManager and NSTextField to render links using the default link appearance, similar to iOS. (see AppKit release notes for 10.12.)
To promote consistency, the intended behavior is for ranges that represent links (specified via NSLinkAttributeName) to be drawn using the default link appearance. So the current behavior is the expected behavior.
(emphasis mine)
This answer is not a fix for the issue of NSLinkAttributeName ignoring custom colors, it's an alternative solution for having colored clickable words in NSAttributedString.
With this workaround we don't use NSLinkAttributeName at all, since it forces a style we don't want.
Instead, we use custom attributes, and we subclass the NSTextField/NSTextView to detect the attributes under the mouse click and act accordingly.
There's several constraints, obviously: you have to be able to subclass the field/view, to override mouseDown, etc, but "it works for me" while waiting for a fix.
When preparing your NSMutableAttributedString, where you would have set an NSLinkAttributeName, set the link as an attribute with a custom key instead:
theAttributedString.addAttribute("CUSTOM", value: theLink, range: theLinkRange)
theAttributedString.addAttribute(NSForegroundColorAttributeName, value: NSColor.orange, range: theLinkRange)
theAttributedString.addAttribute(NSCursorAttributeName, value: NSCursor.arrow(), range: theLinkRange)
The color and content for the link is set. Now we have to make it clickable.
For this, subclass your NSTextView and override mouseDown(with event: NSEvent).
We will get the location of the mouse event in the window, find the character index in the text view at that location, and ask for the attributes of the character at this index in the text view's attributed string.
class MyTextView: NSTextView {
override func mouseDown(with event: NSEvent) {
// the location of the click event in the window
let point = self.convert(event.locationInWindow, from: nil)
// the index of the character in the view at this location
let charIndex = self.characterIndexForInsertion(at: point)
// if we are not outside the string...
if charIndex < super.attributedString().length {
// ask for the attributes of the character at this location
let attributes = super.attributedString().attributes(at: charIndex, effectiveRange: nil)
// if the attributes contain our key, we have our link
if let link = attributes["CUSTOM"] as? String {
// open the link, or send it via delegate/notification
}
}
// cascade the event to super (optional)
super.mouseDown(with: event)
}
}
That's it.
In my case I needed to customize different words with different colors and link types, so instead of passing just the link as a string I pass a struct containing the link and additional meta information, but the idea is the same.
If you have to use an NSTextField instead of an NSTextView, it's a bit trickier to find the click event location. A solution is to create an NSTextView inside the NSTextField and from there use the same technique as before.
class MyTextField: NSTextField {
var referenceView: NSTextView {
let theRect = self.cell!.titleRect(forBounds: self.bounds)
let tv = NSTextView(frame: theRect)
tv.textStorage!.setAttributedString(self.attributedStringValue)
return tv
}
override func mouseDown(with event: NSEvent) {
let point = self.convert(event.locationInWindow, from: nil)
let charIndex = referenceView.textContainer!.textView!.characterIndexForInsertion(at: point)
if charIndex < self.attributedStringValue.length {
let attributes = self.attributedStringValue.attributes(at: charIndex, effectiveRange: nil)
if let link = attributes["CUSTOM"] as? String {
// ...
}
}
super.mouseDown(with: event)
}
}