Monthly Archives: April 2015

Scrolling Text View Workarounds

In Creeping Text Views, I described two bugs in AppKit’s NSTextView that affect MarsEdit’s HTML text editor.

I haven’t found any bona fide solution to the problems, nor have I heard anything from Apple about whether the bug are indeed issues in AppKit or something I can alleviate through changes on my end. Hopefully these behaviors will be considered buggy and fixed by Apple for the benefit of all, but in the mean time I have come up with workarounds for each of the issues that seem to address the problems in a safe, reliable manner.

Jumping Insets: Workaround

Recall that in the case of this bug, an NSTextView whose “textContainerInset” is non-zero will experience a jarring “jump” when the text view is resized such that the text becomes compressed into a smaller visible space. Thus, for the sake of making it very obvious, a text view with a large 50 point textContainerInset:

Jumping Insets

Will be scrolled after simply resizing the window, up to or equal to the amount of the inset itself:

Jumping Insets

I spent some time in the disassembler trying to figure out what causes NSTextView to impose this unwanted scroll, and finally came upon an internal method, called whenever a view finishes resizing:

- (void) _adjustedCenteredScrollRectToVisible:(NSRect)theRect forceCenter:(BOOL)forceCenter

What happens is you click to resize, you grow or shrink the window, then NSTextView recomputes the visible glyph area for the text view, and tries to scroll that specific rect to visible. The root of the bug lies in the fact that, if the computed rect is larger than the clip view then the rectangle is adjusted so that it will be centered on the clip view. This “centering” seems to be what causes the scroll view to move my content up a bit with each resize.

Why is the computed rectangle coming up bigger (taller) than the target clip view, every time? I’m not 100% sure of this, but I think what’s happening is NSTextView is using the size of the view with the textContainerInset to ask the NSLayoutManager what subset of the text should be displayed. This results in striving to display a larger amount of text than will fit in the clip view, and thus it has to be centered vertically in a compromise to fit “as much as possible.”

I observed that the problematic code path in NSTextView only seems to be reached when a live resizing ends. For example, at no time during the live resize does the content shift upwards. Through some debugging I determined that the key method that drives all of this problematic resizing is a public NSView method, documented for handling just this type of scenario: -viewDidEndLiveResize.

So, to review: NSTextView imposes a problematic scrolling of my text content, caused by its inaccurate determination of its own visible glyphs. The inaccuracy is caused by the presence a non-zero textContainerInset on the NSTextView. My workaround? Convince NSTextView, while responding to the end of live resizing, that there is in fact no textContainerInset. In my custom NSTextView subclass, I added my own implementation of -viewDidEndLiveResize:

- (void) viewDidEndLiveResize
{
	NSSize originalInset = [self textContainerInset];
	[self setTextContainerInset:NSMakeSize(0, 0)];
	[super viewDidEndLiveResize];
	[self setTextContainerInset:originalInset];
}

In my limited testing, this completely solve the problem of jumping insets, and doesn’t adversely affect layout or redrawing of the text view in any other way.

Creeping Text Views: Workaround

While the jumping insets bug imposes an obnoxious, unwanted vertical scroll, at least it can be easily corrected by a user: they just scroll the document back to the top. The creeping text views issue is worse because it gradually increases the width of an affect vertical placement of the scroller within a, the creeping text views issue is more insidious in that it causes a text view that is not horizontally resizable to nonetheless became dramatically wider than its clip view ordains:

CreepingTextView

For this bug, the issue boils down to a discrepancy between the frame set on an NSClipView, the frame it in turn sets on its documentView (the text view), and finally the frame that the text view itself ends up setting on itself, in order to “more accurately” accommodate the text it contains.

NSClipView imposes its own bounds on the NSTextView, but the NSTextView may, depending on the specific text content, font, and layout configuration, change those bounds to suit its needs. One common scenario in which this occurs is when the frame being set is non-integral. In this case NSTextView seems to react by rounding up to the closest integral width or height.

Non-integral dimensions used to be pretty easy to ignore, but on Retina Macs they are common-place. Thus when a 410.5 point wide NSClipView imposes its width on NSTextView, the clip view remains 410.5, but the NSTextView jumps up to 411. Once the “same width” contract is broken between these two views, it just seems to get worse and worse with every resize.

I decided to tackle this problem by limiting the clip view such that it will never be a non-integral width. Because the clip view is subservient to an NSScrollView, I know that manipulating its specific width should not affect the layout of the rest of my user interface. The NSScrollView in this scenario may still have a non-integral width, but if it is e.g. 410.5 points wide, the NSClipView within it will always be 410 points. I achieve this by using a custom NSClipView subclass, and overriding the two pertinent methods for setting the frame:

- (void) setFrameSize:(NSSize)newSize
{
	newSize.width = floorf(newSize.width);
	[super setFrameSize:newSize];
}

- (void) setFrame:(NSRect)frame
{
	frame.size.width = floorf(frame.size.width);
	[super setFrame:frame];
}

This solution feels slightly risky in that I’m interfering with the layout contract between NSScrollView and NSClipView. But in my (again, limited) tests so far I have not seen any negative side effects, and the bug is 100% addressed by including the above methods in my NSClipView subclass.

Permanent Fixes

I’m hoping that over the coming days, weeks, or months, I’ll hear something from Apple, or learn something from one of you reading this post, that will lead to a permanent fix that doesn’t require these workarounds. In the mean time, I’ll rest easier knowing my insets don’t jump and text views doen’t creep. Phew!

Creeping Text Views

My attention was recently drawn to an issue I think I’ve seen only to a relatively innocuous degree, starting in 10.10.x OS X releases: the content of MarsEdit’s HTML text editor will shift sometimes such that the origin of the text view is not placed where you would expect it to be within the surrounding scroll view.

In every case I observe the problem as a result of resizing the document window and thus resizing the text editor view.

In some cases this has the effect of simply causing the text document to “scroll down” a bit, a change that can easily be corrected by scrolling the document back up. I call this the “Jumping Insets” variant of what I assume are related issues.

In other, more troubling cases the text view’s position moves leftward while its width also expands rightward, until it is either flush with or substantially off the screen. I call this the “Creeping Text View” variant.

I have a problem with MarsEdit s document editor window having to do with Auto Layout

These are not acceptable.

I’ve spent a few days scratching my head and running experiments about these issues, and while I’ve come to a number of conclusions, none of them is completely satisfying. I know less about the “jumping insets” variant, because as I’ve found it less troubling overall, I spent more time investigating “Creeping Text Views.” Here’s what I think I know about it:

  1. The bugs only occur in the context of a Retina (@2x) screen resolution.
  2. The problem manifests as the document view (text view) at first becoming just 0.5 points wider than the clip view, and then creeping ever wider as the disconnect presumably causes the clip view’s normal sizing behavior to break down.
  3. The size discrepancy is introduced when a clip view is set to a non-integral width, and in response the NSLayoutManager associated with the text view proactively changes the frame of the text view to “more accurately” match the text.
  4. I think the problem is new to 10.10.x. OS releases.

I put together a sample project (and pre-built executable), in case you’re curious to try it. I’m curious to know if anybody has advice for how I can work around the issues systematically and in a way that doesn’t compromise my ability to take advantage of dynamic layout too much. Thoughts that have occurred to me include trying to proactively avoid non-integral view widths (hard to guarantee on Retina), and interceding in a custom NSClipView to round-down instead of up when the document view’s frame is being set to a non-integral value.

I filed these bugs together because they seem related, and because the emphasis is on “Creeping Text Views.” Radar #20487339.