Mutating Struct and State Observers: How the Latter Will Be Notified Even for 'No-Ops'

I may be 7 years late or so – but until last week, I didn’t realize that didSet property observers would fire when the observed property hasn’t actually changed. All you need is a mutating func that doesn’t even need to mutate.

Observation

This can be illustrated with a simple piece of code:

struct State {
    struct InnerState {
        mutating func touch() {
            print("InnerState.touch")
        }
    }

    var innerState: InnerState {
        didSet {
            print("did set to \(innerState)")
        }
    }
}

var state = State(innerState: .init())
state.innerState.touch()
// => InnerState.touch
// => did set to InnerState(value: 1)

Please don’t throw stones, for I was suffering from magical thinking:

I was under the (wrong) assumption that the mutating func needed to, well, somehow mutate the receiver of that method call, like change a property value. And that this in turn would be noted “somewhere”. Conversely, I was under the (wrong) assumption that a mutating func without any mutations inside would behave 100% like a regular, non-mutating function.

To my surprise, the innerState.touch() call marks innerState as mutated, and thus the accompanying State’s property observer fires. So now I have to rethink quite some stuff.

Consequences

Here’s two consequences that come to mind for my day-to-day work:

Expose changes to reference type objects in your value types

A pretty nice consequence is that you can use mutating func to change a reference type property inside a value type, and have references to the value type still know that it has changed:

class ReferenceType { ... }

struct ValueType {
    // `private` to discourage direct access, bypassing the mutating func:
    private let referenceType = ReferenceType()
    mutating func touchReferenceType() {
        referenceType.doSomething()
    }
}


var valueType: ValueType = ... {
    didSet { print("did change") }
}

valueType.touchReferenceType()
// => did change

This technique requires that you don’t reach inside the ValueType and perform mutations inside its reference type property directly; everything needs to go through a mutating func as a Façade or Adapter of sorts.

Value type app state changes could fire without actually changing anything

If you use ReSwift or another approach to unidirectional flow based on state representations via value types, calling a mutating func will notify all state subscribers of the change – even when there hasn’t been a change.

As a consequence, the following code with a guard statement that exits early (and does not mutate) will still trigger a didSet property observer of State:

struct State {
    // ...
    mutating func handle(action: Action) {
        guard action.satisfiesSomeCondition else { return }
        self.substate = action.newValue
    }
}

This handle(action:) function is a generic placeholder for state reducers.

Usually, you’ll be checking for oldState != newState in your subscriber to filter idempotent states. But this will trigger the state equality check even when nothing has changed.

That got me thinking: should I be filtering potentially expensive state checks before they reach the reducers?

In ReSwift, there are middlewares that can “swallow” or filter actions, i.e. refuse to forward the incoming action to the state. So:

When comparing e.g. large strings or complex objects for equality, and if these checks become a performance bottleneck or happen at high frequencies, it may be useful to filter the actions before they trigger the didSet property observer for the state.

Wiki Updates for Nov 29th

I just wanted to write one short post. Then I discovered that I didn’t yet have a wiki page for a central concept. Then I needed another one. And an hour later, here we are.

New wiki pages:

And a new library entry:

Org-Mode Outline Levels 9+

Emacs’s outline-mode only has font settings aka “faces” for 8 outline levels; then they wrap, so level 9 looks like level 1 and so on.

org-mode inherits these faces, and thus also only defines 8 styles.

That’s more than enough, I believe, to have some visual variety in your outlines and also sufficient distance between repeating styles.

One this that does absolutely not gel with this is using level-1 outline items as actual headings, using e.g. a larger font (often 2x the size), maybe even variable pitch to make it more distinct, because this style would be used for levels 1, 9, 17, 25, etc.

Because I do have large level-1 headings at the moment, but also work with a rather deeply nested outline to document call stacks, I needed more regular-size outline items.

The bottommost level-9 item repeats the large level-1 style to the left; with my changes to the right, it looks ok.

To skip repeating the rather large level-1 style, I increased the supported headings with repeating styles from level-2 onward like this:

(defface org-level-9 '((t :inherit org-level-2))
  "Face used for level 9 headlines."
  :group 'org-faces)
(defface org-level-10 '((t :inherit org-level-3))
  "Face used for level 10 headlines."
  :group 'org-faces)
(defface org-level-11 '((t :inherit org-level-4))
  "Face used for level 11 headlines."
  :group 'org-faces)
(defface org-level-12 '((t :inherit org-level-5))
  "Face used for level 12 headlines."
  :group 'org-faces)
(defface org-level-13 '((t :inherit org-level-6))
  "Face used for level 13 headlines."
  :group 'org-faces)
(defface org-level-14 '((t :inherit org-level-7))
  "Face used for level 14 headlines."
  :group 'org-faces)
(defface org-level-15 '((t :inherit org-level-8))
  "Face used for level 15 headlines."
  :group 'org-faces)
(setq org-level-faces (append org-level-faces (list 'org-level-9 'org-level-10 'org-level-11 'org-level-12 'org-level-13 'org-level-14 'org-level-15)))
(setq org-n-level-faces (length org-level-faces))

The idea came from a StackOverflow post which I simplified by replicating the defface’s of the regular org levels instead of using custom settings.

Gitea Ltd. Takes Over Gitea Open Source Project, Community Pushes Back

I discovered this piece of news by accident, and I want to share this with you because I believe this is a very interesting case, providing insight into open source projects, even those that are wildly popular.

Gitea is an open source “git forge” (think: GitHub; or rather GitLab, because you can self-host Gitea), itself a fork of Gogs, which also still exists, but with 1/3 of the contributors. Its Open Collective budget reveals that the project raised US$ 35k in total and has an annual estimated budget of 14k. That’s not nothing, but it’s also not financing a full-time maintainer.

So a company is formed to offer services as a means to fund maintenance; then the community of contributors and fans pushes back because the proprietary ownership doesn’t sit right with them.

Since I’m not involved, I’m likely missing a lot of nuances. The timeline, as I gathered:

  1. Gitea Ltd. was incorporated. At least two admins/owners announce that this company will offer paid services, and that it’s becoming the steward of the Gitea open source project: https://blog.gitea.io/2022/10/open-source-sustainment-and-the-future-of-gitea/

  2. An open letter, signed by 48 Gitea community contributors, asks for a different set up. Namely a non-profit organization taking stewardship instead of the for-profit Gitea Ltd.: https://gitea-open-letter.coding.social/ – Spoilers: later, they prepended an announcement to the actual open letter in reaction to what follows.

  3. Lunny Xiao of “the Gitea team”/Gitea Ltd. politely declines: https://blog.gitea.io/2022/10/a-message-from-lunny-on-gitea-ltd.-and-the-gitea-project/ – note that Lunny Xiao has been one of the three annually elected ‘owners’ since the beginning. He created the Gitea fork, and was also one of the founders of Gogs. For all intents and purposes, he could’ve been the BdfL if the Gitea project didn’t have this interesting community ownership model.

  4. A Gitea fork is being created, named “Forgejo”: https://codeberg.org/forgejo/forgejo – it’s hosted on the Gitea-powered forge at codeberg.org, which itself is a German non-profit. (I recently learned that creating a legal entity, a non-profit ‘e.V.’ or association in Germany is much simpler than, say, Canada, so this might have been a good pick.)

With Giteas interesting election procedure of stewards aka “owners”, the move to incorporate a Ltd. that now seems to legally own the domain and trademarks doesn’t align with the values expressed in the community documents. At least that part of project ownership won’t be transferred in the upcoming January election anymore.

This breaks the social contract, part of which is likely mere community expectation and not legally binding, but even moreso a punch in the face of those who shared the vision.

What I also find interesting about this story is: if you release a project into the world as open source (MIT licensed, aka “anything goes”) and build a community around shared ownership, you can’t really take it back anymore – if that’s what Lunny Xiao (and others) try to; I could be misreading this.

As the open letter stated:

[T]here has been some confusion as to the ownership of the Gitea domains and trademarks, which are essential parts of the project […] (if not the most important).

And:

[Y]you can understand our surprise when we learned on October 25th, 2022 that both the domains and the trademark were transferred to a for-profit company without our knowledge or approval.

The name, the domain, the trademark, that’s truly the powerful piece of an open source project. In short, it’s the brand. And even though every contributor can pack up and move to Forgejo, the brand is not moving with them. It’s a huge loss for the health of the project. (And I’m not sure whether the new name will prove quite as catchy.)

CocoaHeads Talk about JavaScriptCore Plugins on Thursday 2022-11-24, 7 p.m.

This Thursday (in 2 days), I’ll be doing a short presentation of plugin systems via JavaScriptCore, plus an interactive demo and something for attendants to play around with. (Fingers crossed I finish it in time 😱)

You can join via Zoom. It’s free, and should be fun :)

There’ll likely be a recording if you can’t make it.

See you then!

Smooth Scrolling in a Table View for Custom Shortcuts

NSTableView comes with a couple of default shortcuts in an override of its keyDown(with:) method. These aren’t mentioned anywhere, so Xcode’s documentation quick help will repeat the NSView docstring to you, saying:

The receiver can interpret event itself, or pass it to the system input manager using interpretKeyEvents(_:). The default implementation simply passes this message to the next responder. [Emphasis mine]

The last sentence is misleading in this case: while the default implementation does nothing, NSTableView’s does something. Like bind the Page Up/Page Down keys to scroll its enclosingScrollView with an animation.

Now if you find yourself in a situation where you want to handle the keyboard event yourself, you maybe using:

class MyTableView: NSTableView {
    override func keyDown(with event: NSEvent) {
        nextResponder?.keyDown(with: event)
    }
}

This will, as the original docs indicate, forward the event.

In the responder chain, there’ll likely be a view controller – and that’s where I usually handle my key events. Outside the view, that is.

class MyTableViewController: NSViewController {
    override func keyDown(with event: NSEvent) {
        interpretKeyEvents([event]}
    }
}

By interpreting key events, all the default and nicely named NSResponder methods are available. So you can implement different shortcuts for moving the selection. Like +Arrow Keys to maybe jump only 10 items, and +Arrow Keys to go to the first/last row in the table.

In that case, you need to re-implement the paging keys again, though.

While NSScrollView implements pageUp(_:) and pageDown(_:), these aren’t animated. So the default table view implementation of the pagination keys does something different. And sadly, NSTableView does not react to these methods at all, so it’s hidden as an implementation detail in the key handler which we just overwrote.

Observation: What AppKit Seems to be Doing

The scroll view’s document view, aka the NSClipView, is being scrolled using a private _scrollTop:animateScroll:flashScrollerKnobs: method. I found that by subclassing NSClipView and adding a breakpoint to scroll(to:):

#0	0x00000001028d0c74 in MyClipView.scroll(to:) at AppDelegate.swift:21
#1	0x00000001028d0d30 in @objc MyClipView.scroll(to:) ()
#2	0x00000001b68dc4e0 in -[NSScrollView scrollClipView:toPoint:] ()
#3	0x00000001b68740a4 in -[NSClipView _scrollTo:animateScroll:flashScrollerKnobs:] ()
#4	0x00000001b718103c in -[NSScrollView _scrollPageInDirection:] ()
#5	0x00000001b710e44c in _NSPostProcessKeyboardUIKey ()
#6	0x00000001b6b133f8 in -[NSWindow keyDown:] ()
#7	0x00000001b69f11d4 in forwardMethod ()
#8	0x00000001b69f11d4 in forwardMethod ()
#9	0x00000001b69f11d4 in forwardMethod ()
#10	0x00000001b69f11d4 in forwardMethod ()
#11	0x00000001b6b57b94 in -[NSControl keyDown:] ()
#12	0x00000001b6b6b32c in -[NSTableView keyDown:] ()
#13	0x00000001028d0550 in MyTableView.keyDown(with:) at AppDelegate.swift:12

That’s not helping at all in terms of replicating the behavior, but it confirms my suspicion.

Possible fix: Animate scrolling manually

NSClipView’s scroll methods don’t seem to work with the animator() proxy.

But “scrolling” a scroll view vertically, for example, boils down to changing the clip view’s bounds.origin.y. And we can animate that.

In principle, the call is this:

myTableView
    .enclosingScrollView?
    .contentView
    .animator()
    .bounds.origin.y = /* new value here */

What’s the new Y value when you use the pagination keys?

NSScrollView has a verticalPageScroll property. It’s oddly named in my opinion, since it doesn’t equal to the “delta Y”, i.e. the scrolled pixels, but the amount of pixels that should not be scrolled when paging.

The default value is 10.0 and that means every page up/page down scrolls 100% of the available content’s height, minus 10pt. This is basically the visual anchor that is left on screen. Like when you scroll down, you see the bottommost row at the top position.

This is an estimate that works for my case to calculate the actual ‘delta y’:

extension NSScrollView {
    /// Calculates the offset or delta based on the scrollable content
    /// height and ``verticalPageScroll``, which determines how much
    /// of the previously visible content should remain visible.
    var verticalScrollDelta: CGFloat {
        contentView.bounds.height - verticalPageScroll
    }
}

Using this value, it’s far simpler to custom scroll and animate that.

// In doCommand or similar callbacks for interpreting the key events:
switch selector {
case #selector(NSResponder.scrollPageUp(_:)),
     #selector(NSResponder.pageUp(_:)):
    if let scrollView = myTableView.enclosingScrollView {
        let newOriginY = scrollView.contentView.bounds.origin.y
          - scrollView.verticalScrollDelta
        scrollView.contentView.animator().bounds.origin.y =
            max(0, newOriginY)
    }
case #selector(NSResponder.scrollPageDown(_:)),
     #selector(NSResponder.pageDown(_:)):
    if let scrollView = myTableView.enclosingScrollView {
        let newOriginY = scrollView.contentView.bounds.origin.y
            + scrollView.verticalScrollDelta
        scrollView.contentView.animator().bounds.origin.y =
            min(newOriginY, myTableView.bounds.height)
    }
// ...
}

This scrolls smoothly, and it looks just like the NSTableView default.

I also found that it was necessary to use origin.y = min(..., ...) and max(..., ...), respectively. Merely adding or subtracting the delta via -= and += didn’t animate properly, even though the resulting clip view’s viewport was properly clamped, i.e. there was no “overscroll”. But clamping the values manually isn’t a hassle, either.

Keep in mind that your scroll view’s configuration may be different: you may have insets that should be taken into account during these calculations. YMMV. I have no active insets, so the heights and offsets I use work fine.

There Could Be No “Later”

In recent weeks, I’ve been feeling quite restless. There’s not enough time, too many things I just can’t seem to take care of.

You probably know that feeling – or rather, the attitude towards things.

There’s a couple of things that need to be sorted out. And there’s stuff that arrives on my desk and wants to be taken care of. Like taxes.

My insight of the past days itself isn’t anything worth sharing, really: There may be no better time later to take this on. It might just as well get more and more busy. So it’s not worth just putting stuff onto a (mental) pile. Decide to never do it, or do do it. Deferring via seemingly endless to-do lists doesn’t work for me.

From a technical, project-managing perspective, this is reflected in sentiments like “Don’t Keep a Backlog” (since chances are that other, more important stuff gets in the way before you get to it). See this sentiment reflected in e.g. “Kill Your Product Backlog”, or basically everything by DHH and Shape Up, just to name a few examples.

The danger, in principle, is quite severe. If I choose to ignore things even though these things are in plain sight, this can change my perception of the rest of reality, too. Overlook the socks on the ground often enough, and they become invisible. It’s an odd magic trick. In that case, one is basically playing a game of pretend, slipping into selective denial. The brain then does the rest to make it work. This automatism is the actual dangerous bit because blind spots can manifest.

While selective perception is a useful coping mechanism to focus on some important tasks while ignoring other things that would distract, eventually, one has to get out of that.

It feels like I let the ideal moment to stop pretending pass by – so today is the next best opportunity.

The danger of denial feels quite real, and that’s not a good feeling, and I believe that’s what contributes to the restlessness the most: the externalization of my initial decision to “do this later”, which became an auto-deferral of sorts, and then the resulting narrative shift towards one of powerlessness in face of Spirits that I’ve cited / My commands ignore1, of “I can’t do this right now”, while actually I merely won’t. The initial decision became reality, which then informs future decisions.

This personal insight that the situation requires a small amount of will and action to fix is nothing ground-breaking, per se.

I figured what might be worth sharing is the meta level:

Even though this is nothing new, and even though I’ve read about this dozens, maybe hundreds of times over the decades – it is still totally possible to fall for the trap of “later forever”, of deferring tasks day after day, of leaves in the garden nobody cleans up, or things that are broken in the house and remain unfixed for ages, of amassing long “next action” lists and huge backlogs. Unlike physical paper, a text file doesn’t push into your face how long it gets. It’s non-physicality makes deferring feel like an innocent act.

So my contribution to the internet is this: Knowing about all this helps to identify the problem, but it’s not the solution: action is. Which is also well-known. Still we can find ourselves in situations like this.

  1. Goethe’s writing is full of quips that became proverbs in German, and here I’m citing one of them. Chances are you never heard this :) This is from The Sorcerer’s Apprentice. The part towards the end I’m citing here (“Ah, he comes excited. Sir, my need is sore. / Spirits that I’ve cited / My commands ignore.”, in German: “Ach, da kommt der Meister! / Herr, die Not ist groß! / Die ich rief, die Geister / werd ich nun nicht los.”) is one proverbial phrase, sometimes shortened to merely “Die Geister die ich rief …”, i.e. “The spirits that I’ve cited”. The meaning is roughly: It was in one’s power, once, to conjure the spirits and start a process, which then accidentally became perpetual, but it’s not of one’s power to make this stop. It’s a moment of profund insight (I caused this!) and despair (I can’t stop it!). – If my inner apprentice brought forth this situation, who’s the master to fix everything? Why, that’s also me, of course! Merely acting on a different level. 

NSTableView Variable Row Heights Broken on macOS Ventura 13.0

Variable row heights in your NSTableView might be broken in your apps on macOS Ventura 13.0 – it’s fixed with the upcoming 13.1, but that’s only available as a beta at the moment.

When you replace table contents by calling aTableView.reloadItems(), this will ingest the new data as usual, but the old row heights won’t be forgotten. This can affect scrolling. The row height cache, it seems, isn’t properly invalidated or cleared.

NSTableView and NSOutlineView now automatically estimate row heights for view-based table views whose delegates implement tableView(_:heightOfRow:) and provide variable row heights. This provides performance improvements for table views with large numbers of rows by reducing the frequency of the calls to tableView(_:heightOfRow:).

(macOS 13 Release Notes)

Kuba Suder summarized this:

In macOS Ventura NSTableView now calculates the row heights lazily:

  • row heights are calculated for rows which are in or near the scrolling viewport
  • for the rest of the rows, NSTableView estimates the height based on the row heights that it has already measured
  • as the table is scrolled, the table view requests more row heights as needed and replaces the estimates with real measurements

@SkipRousseau shared how to fix this in a Slack:

  1. Your app can opt out of row height estimation.

    Test this via launch arguments in Xcode by adding -NSTableViewCanEstimateRowHeights NO. That essentially overrides a UserDefaults key.1

    To do this programmatically, call UserDefaults.standard.set(true, forKey: "NSTableViewCanEstimateRowHeights") early in the app’s launch routine, e.g. during applicationWillFinishLaunching.

  2. Invalidate the cache for each row index, in code via NSTableView.noteHeightOfRows(withIndexesChanged:). Skip’s sample to invalidate the cache for all rows is:

     // Right after reloadData,
     NSAnimationContext.beginGrouping()
     NSAnimationContext.current.duration = 0
     let entireTableView: IndexSet = .init(0 ..< self.tableView.numberOfRows)
     self.tableView.noteHeightOfRows(withIndexesChanged: entireTableView)
     NSAnimationContext.endGrouping()
    

For now, I tend towards disabling NSTableViewCanEstimateRowHeights by default to get the old behavior and to prevent the bug on Ventura, and optionally enable this for macOS 13.1 during the launch procedure. Opting-out of the new behavior sounds easier to maintain than invalidating row indexes on 13.0 in code.

  1. UserDefaults don’t update when you put this key–value-pair into your Info.plist. Thanks Daniel Alm for reporting this! 

macOS Ventura App Compatibility

Happy macOS release week, everyone!

Earlier this week, macOS 13 Ventura was released. It’s a point-oh release, so you might want to sit this out a bit and wait for 13.1, as is common practice. (My main developer machine is running Big Sur, and I won’t be updating for a couple of weeks more, maybe not before December.)

No matter how early an adopter you are – I’m happy to report that all my apps are “Ready for Ventura”, as you say:

And all run fabulously on Ventura!

macOS 10.10 Yosemite, 10.11 El Capitan, 10.12 Sierra Support

With heavy heart, I’ll eventually have to drop some of the older macOS versions.

Apple’s latest version of their own app development tool drops support for macOS 10.12 or older. (Planned obsolescence or sane practice?) I can get by with an older version of their tool for a while, but eventually, I’ll have to bump the minimum required macOS version to 10.13 High Sierra, which was released 2017.

I’m proud to support older macOS versions, to be frank. TableFlip runs on an operating system from 8 years ago! But the user base and thus the need for this is minuscule.

So some time next year, most likely, I’ll be bumping the minimum OS version to all apps. And maybe even ship a new major version, including some larger overhauls.

Until then, enjoy your apps!

Sketch Employees for Hire

This list was extracted from my reaction to Sketch laying off 80 employees for higher visibility.

Update 2022-10-18: There’s a spreadsheet overview of 57 Sketch alumni you can check instead!


Here are some amazing ex-Sketch folks that I’ve found thus far who are for hire now:

Please forward job searches, retweet their announcements, and maybe send digital hugs.


→ Blog Archive