Post

Replies

Boosts

Views

Activity

Reply to The NSTextViewDelegate method textViewDidChangeSelection(:) will not fire, while all other text view delegate methods do.
Thank you for responding. The firstTextView is the only text view that is ever passed as the notification.object when a series of text views are managed by an NSLayoutManager, so checking against layoutManager.firstTextView or coordinator.textViews.first or coordinator.textViews[0] are functionally equivalent for me -- whichever I use, the check works in my code and isn't my issue. I shouldn't have muddied my post by talking about the multiple notifications, sorry for the confusion. My primary issue is that the delegate function is never called. I can get textViewSelectionDidChange(:) to fire if I add my coordinator as an observer to the notification. But I would like to understand why it won't fire at all via a delegate call when I set my coordinator as the view's delegate. Thanks for any further help with this!
Topic: UI Frameworks SubTopic: AppKit Tags:
May ’25
Reply to NSLayoutManager Bug -- layout manager re-laying out overlapping text into the same container.
Hi Ziqiao, thank you for the suggestion. I tested using exclusionPaths instead and interestingly, the bug still occurs! I can send you the version of the project that uses exclusionPaths instead of different container sizes if you'd like to compile/run and see. For convenience, here is the changed code in question with exclusionPaths: setupLayout() changes to: func setupLayout() { var index = 0 for textViewData in PreviewTestData.textViewDataArray { self.ensureContainer(with: textViewData.frame.size, exclusionPath: textViewData.exclusionPath, at: index) index += 1 } } ensureContainer(...) changes to: @discardableResult private func ensureContainer(with size: CGSize, exclusionPath: NSBezierPath?, at index: Int) -> NSTextContainer { precondition(index >= 0 && index <= layoutManager.textContainers.count) if index == layoutManager.textContainers.count { let container = NSTextContainer(size: size) container.widthTracksTextView = false container.heightTracksTextView = false container.lineFragmentPadding = 0.0 container.exclusionPaths = exclusionPath == nil ? [] : [exclusionPath!] layoutManager.addTextContainer(container) return container } else { let container = layoutManager.textContainers[index] container.exclusionPaths = exclusionPath == nil ? [] : [exclusionPath!] if container.size != size { container.size = size } return container } } And the test data array changes to: // Hard-coded test data array: static let textViewDataArray: [(page: Int, frame: CGRect, exclusionPath: NSBezierPath?)] = [ (page: 1, frame: CGRect(x: 0, y: 72, width: 612, height: 648), exclusionPath: nil), // Single column (page: 2, frame: CGRect(x: 0, y: 72, width: 612, height: 648), exclusionPath: NSBezierPath(rect: CGRect(x: 0, y: 0, width: 306, height: 648))) // Left column ] P.S. I understand that TextKit 2 is the (eventual) future but it currently only allows a very barebones single text container. A developer could only use Text Kit 2 to make a simple, single pane TextEdit app but nothing beyond that. Text Kit 1 is the only technology Apple offers for multi-page text. There is no alternative.
Topic: UI Frameworks SubTopic: AppKit Tags:
May ’25
Reply to NSLayoutManager Bug -- layout manager re-laying out overlapping text into the same container.
Hi Ziqiao, thank you for looking at the project and for the feedback. My word processing app requires containers of differing width in order to fulfill its core functions. I know Apple's own Pages app supports documents that flow continuously with varying numbers of columns with widths that the user can set arbitrarily. And Apple's own documentation of NSLayoutManager / NSTextContainer mentions multi-column text (https://developer.apple.com/library/archive/documentation/TextFonts/Conceptual/CocoaTextArchitecture/TextSystemArchitecture/ArchitectureOverview.html#//apple_ref/doc/uid/TP40009459-CH7-SW4). Text Kit 2 doesn't yet support more than one text container, so that's not an option for me yet and I can't migrate to Text Kit 2 until they support container / page breaks. I would love for this issue to be elevated. This is an actual and serious bug in Text Kit!
Topic: UI Frameworks SubTopic: AppKit Tags:
May ’25
Reply to NSLayoutManager Bug -- layout manager re-laying out overlapping text into the same container.
I'm sharing the code that parses my preview data here. I'm using an NSViewRepresentable and the call to - paginator.exhibitTheBug() - is within makeNSView(...). But note that this bug occurs whether I call paginator.exhibitTheBug() there, or in updateNSView(...), or from textStorage(:didProcessEditing:...) via a DispatchQueue.main.async closure. I.e., I thought at first that it was a threading/runtime issue, but it's not. class Paginator { let textStorage: NSTextStorage let layoutManager: NSLayoutManager init(textStorage: NSTextStorage, layoutManager: NSLayoutManager) { self.textStorage = textStorage self.layoutManager = layoutManager self.setupLayout() } /// Runs the functions that will exhibit the bug. func exhibitTheBug() { // Get char range to measure in container. let characterRangeToTest = self.getCharacterRangeOfFirstColumnType() // The bug occurs in this call: testMeasurement(for: characterRangeToTest) } /// Returns the effective range of the text storage over which the column type (`NSAttributedString.Key.columnType`) assigned at location 0 applies. Note: This range of the textStorage includes more than 20 newlines. private func getCharacterRangeOfFirstColumnType() -> NSRange { var firstColumnTypeRange: NSRange = NSRange() let rangeLimit = NSRange(location: 0, length: textStorage.length) // Get the column type at the text storage start location, then we return the effective range that it applies to. let columnTypeString = textStorage.attribute(.columnType, at: 0, longestEffectiveRange: &firstColumnTypeRange, in: rangeLimit) as? String assert(columnTypeString != nil) return firstColumnTypeRange } /// The function that exhibits the bug. private func testMeasurement(for columnTypeRange: NSRange) { assert(layoutManager.textContainers.indices.contains(0)) let firstContainer = layoutManager.textContainers[0] let glyphRange = layoutManager.glyphRange(forCharacterRange: columnTypeRange, actualCharacterRange: nil) let glyphIndex = NSMaxRange(glyphRange) - 1 var glyphRangeInContainer = NSRange() // Will be updated by layout calls. // Determine which container the glyph is in. let glyphContainer = layoutManager.textContainer(forGlyphAt: glyphIndex, effectiveRange: &glyphRangeInContainer) if glyphContainer === firstContainer { // The glyph is in the first container. let textRangeInContainer = layoutManager.characterRange(forGlyphRange: glyphRangeInContainer, actualGlyphRange: nil) // We have NOT overflowed from // the container. // We now check the lineFragmentRect, // which is **in the firstContainer's // coordinate system**. let glyphLineFragmentMaxY = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil, withoutAdditionalLayout: true).maxY print("This is the bug!") // !!!!BUG!!!! // Data reads here: // glyphIndex = 191 // glyphContainer === firstContainer // glyphContainer!.size.height = 648.0 // glyphLineFragmentMaxY = 14.0 // // I have wired the text containers and text // views to the layout manager correctly. // There are six of them, yet... // // ALL of the text lays out only into the // first container. Note: if I change my data even // slightly, e.g., modifying container/view sizes // by only one point, then this doesn't occur! } } /// Ensures that the layout manager has a container with the proposed `size`, at `index` within the Pagintor's text containers array. Returns the the text container for convenience. @discardableResult private func ensureContainer(with size: CGSize, at index: Int) -> NSTextContainer { precondition(index >= 0 && index <= layoutManager.textContainers.count) if index == layoutManager.textContainers.count { let container = NSTextContainer(size: size) container.widthTracksTextView = false container.heightTracksTextView = false container.lineFragmentPadding = 0.0 layoutManager.addTextContainer(container) return container } else { let container = layoutManager.textContainers[index] if container.size != size { container.size = size } return container } } /// Creates initial text containers from the hard-coded preview data. PreviewTestData.textViewDataArray is of type [(page: Int, frame: CGRect)]. The NSViewRepresentable's Coordinator uses this same data to create flipped NSTextViews. func setupLayout() { var index = 0 for textViewData in PreviewTestData.textViewDataArray { self.ensureContainer(with: textViewData.frame.size, at: index) index += 1 } } } /// A lightweight struct that carries info we use in order to build our containers/views. struct PlacementInfo: Equatable { let page: Int let minY: Double let segmentNumber: Int } /// A lightweight struct used to determine whether (and how) text fits in a given text container. struct ContainerFitResult { /// If non-nil, the location/index of the first character of a body of text that didn't fit into a particular text container. If this value is nil, then the container did not overflow and may need to be sized down (by checking the value of `heightUsed`). let overflowLocation: Int? /// If non-nil, gives the maximum y position of the last line fragment for the container fit test result. If this value is nil, then the entire container was used and there was overflow. let usedHeight: Double } Preview Data in part includes the below, which is also used by my NSViewRepresentable's Coordinator to build the NSTextViews and connect them to the layout manager's containers. There is only one shared layout manager. static let textViewDataArray: [(page: Int, frame: CGRect)] = [ (page: 1, frame: CGRect(x: 0, y: 72, width: 612, height: 648)), (page: 1, frame: CGRect(x: 0, y: 700, width: 324, height: 20)), (page: 2, frame: CGRect(x: 0, y: 72, width: 324, height: 28)), (page: 1, frame: CGRect(x: 324, y: 700, width: 288, height: 20)), (page: 2, frame: CGRect(x: 324, y: 72, width: 288, height: 42)), (page: 2, frame: CGRect(x: 0, y: 114, width: 612, height: 14))
Topic: UI Frameworks SubTopic: AppKit Tags:
May ’25
Reply to NSLayoutManager returning inconsistent values for a glyph's text container and its line fragment rect
I am STILL having this problem. I believe the issue is how NSLayoutManager handles trailing newlines. The glyph at the index/location I'm passing for forGlyphAt is a trailing newline. I think this may be why I'm getting inconsistent results from textContainer(forGlyphAt:effectiveRange:) and lineFragmentRect(forGlyphAt:effectiveRange:). Is anyone else having issues with this?? This is driving me insane.
Topic: UI Frameworks SubTopic: AppKit Tags:
Apr ’25
Reply to NSLayoutManager returning inconsistent values for a glyph's text container and its line fragment rect
Update: When I set a breakpoint and step through the code slowly, all measurements are made correctly. There seems to be something happening asynchronously. Apple documentation for NSLayoutManager says that both lineFragmentRect(forGlyphAt:effectiveRange:) and textContainer(forGlyphAt:effectiveRange:) force glyph generation and layout, but it seems like maybe those generation/layout passes aren't completing synchronously in runtime if I'm getting different results from those calls when stepping through as opposed to when I don't have any breakpoints set.
Topic: UI Frameworks SubTopic: AppKit Tags:
Feb ’25
Reply to NSLayoutManager laying out overlapping text into the same NSTextContainer even when there are more containers available.
Thank you. I decided to keep trying at it - as my code base was a little too much to simplify in order to share it. I couldn't solve it no matter what I did at a granular level. BUT, when I simply removed my measuringLayoutManager and had all code use a single layoutManager, the problems went away. Something in the text system really did not like it when I had more than one layout manager laying out the same text storage, even though I ensured that that second layout manager used a separate set of text containers, and even though those text containers were not tied to any views, nor ever passed as objects/references. Merely having a second layout manager assigned to the text storage, doing layout passes into its own containers in order to calculate sizes seemed to be the source of the problem. I simplified and am using a single layout manager now. My calculated sizes are the same as they were when calculated by my second measuringLayoutManager. So there was nothing wrong with the calculations there. But I no longer get overlapping text and bizarre behavior. I greatly appreciate your attempts to assist me with this. I have another bug now working with NSLayoutManager which I've made a post about here (it's currently being reviewed prior to posting but should post soon). If you see my other post and have any insight with that issue, I'd be very thankful. From my limited experience, NSLayoutManager is pretty finicky and pretty vaguely-documented. This seems to be an agreed upon point of view in the developer community from what I've found online. Unfortunately, TextKit2 is not yet capable of doing the kind of multi-column, multi-page pagination I need. Until it is, I'll have to figure out how to work with NSLayoutManager reliably.
Topic: UI Frameworks SubTopic: AppKit Tags:
Feb ’25
Reply to How can I integrate my own text changes into UITextView's undo manager?
@Mr. Jefferson , did this ever get solved for you? Did you find your own solution or find one elsewhere? @Frameworks Engineer, do you have a follow-up to OP's reply to your comment? I ask because I'm having the exact same issue. I'm coding a word processing app with custom behavior that requires running checks before deleting, and then changing attributes around/near the deletion point after deleting. (I have some other custom behaviors that are affected by this same issue but for simplicity will just refer to the deletion one.) I want to be able to use default NSTextView behaviors that already come with "free" undo/redo (e.g., typing, deleteBackward(:), insertNewline(:), but then want to modify attributes for a certain range of the textStorage afterward. I register my attribute modifications with the undoManager. Like OP, I can get undo/redo to work flawlessly but only when using only NSTextView default behavior OR when using only my modifications/undos to the NSTextStorage. The second I start mixing both, things get ugly. My code looks like this (note: this is for macOS/AppKit but I have the exact same issue so wherever my error lies, it seems cross-platform in scope): //... let oldTypingAttributes = textView.typingAttributes let oldSelectedRange = textView.selectedRange let selectedRangePostDelete: NSRange if selectedRange.length == 0 { // If selection is zero length, then deletion will result in the caret moving one position left selectedRangePostDelete = NSMakeRange((oldSelectedRange.location - 1), 0) } else { // Otherwise, deletion will result in the caret being positioned at the start of the originally-selected range selectedRangePostDelete = NSMakeRange(oldSelectedRange.location, 0) } undoManager?.beginUndoGrouping() textView.deleteBackward(nil) // Note: I have also tried setting selectedRangePostDelete HERE - after deleteBackward - by calling textView.selectedRange, but I opted to move it earlier to be certain that my issue wasn't caused by run loop timing issues i.e., to be sure I wasn't querying textView.selectedRange before it had been updated post-deletion... all this said, the issue occurs either way let changeAttributesRange = NSMakeRange(selectedRangePostDelete.location, aLength) let oldAttributedString = textStorage.attributedSubstring(from: changeAttributesRange) textStorage.beginEditing() textStorage.addAttributes(newAttributes, range: changeAttributesRange) textStorage.endEditing() textView.selectedRange = selectedRangePostDelete let undoHandler: (MyCustomViewRepresentable.Coordinator) -> Void = { [oldAttributedString = oldAttributedString, changeAttributesRange = changeAttributesRange, oldTypingAttributes = oldTypingAttributes, changeAttributesRange = changeAttributesRange] target in textStorage.beginEditing() target.textStorage.replaceCharacters(in: changeAttributesRange, with: oldAttributedString) textStorage.endEditing() // I have tried setting selection range here in the undo handler to either selectedRangePostDelete or oldSelectedRange. I have also tried setting typingAttributes to oldTypingAttributes. Neither seems to have an effect on the issue I'm seeing. } // end undoHandler declaration undoManager?.registerUndo(withTarget: self, handler: undoHandler) undoManager?.setActionName("Typing") undoManager?.endUndoGrouping() //... With this code, the undo/redo stack seems to get corrupted. Behavior will appear as expected for a few cycles of undo/redo but ultimately starts to produce unwanted behavior after a few cycles. My use case precludes me from relying only on NSTextView behavior. So I need to modify the underlying textStorage at times. I realize that I could simply make ALL my changes by modifying textStorage only and prevent/override any default NSTextView modifications to the text, but I really don't want to have to reinvent the wheel if I don't have to. Completely coding my own implementation that modifies textStorage directly for all possible keyDown events that would edit text would be a bit absurd and seemingly unnecessary. I'd love to use NSTextView's standard behavior whenever it's applicable, which is 90% of the time in my case. So I'm desperate to figure out how I can I make NSTextView standard behavior and custom NSTextStorage modification play nice together and fluidly support undo/redo. Any solution has escaped me thus far. I felt relieved to find someone struggling with the same issue! For further context, before calling the above block of code, I am overriding my NSTextView's doCommand(by selector: Selector) and then running ... if selector == #selector(deleteBackward(_:)) { coordinator.handleDelete(for: self) return } ... Where handleDelete(for:) calls the big block of code above, and where coordinator is the coordinator for my custom NSViewRepresentable class, which builds a view to provide to SwiftUI. The NSViewRepresentable view hierarchy contains the NSTextView in question. Note: if you're wondering why in my undo handler closure I call replaceCharacters(in: range, with: oldAttributedSubstring) rather than something like addAttributes(oldAttributes)... it's because at the time of the original addAttributes call, string attributes may differ at various ranges/locations within the range of textStorage in question. To fully restore the textStorage to its original state, I have to completely replace the original attributedSubstring for that range).
Topic: UI Frameworks SubTopic: UIKit Tags:
Dec ’24