Retry Imperative Conditions with RxSwift Using a Delay

In The Archive, people relying on character composition to enter their text noticed that the auto-saving routing got in the way and aborted the composable editing mode.

This affects e.g. Chinese or Japanese character input on macOS, but also when you hit a composable accent like ´ after which the text editor waits for another character to put underneath the accent.

This composing editing mode is handled in NSTextInputClient via what they call “marked text”. The accent is not actually part of the NSTextView.string until you finish the composition. Same with e.g. Chinese input: you hit a bunch of keys and see composition interface on screen, but the actual string isn’t changed until you commit the change.

Since the underlying string isn’t changed, when users activate this composition mode, none of the key presses during that mode fire textDidChange or similar notifications.

When The Archive knows the user is idle, the app reacts to external file changes by displaying them right away. The idle check was bound to text changing notifications until now, so when you were using the keyboard layout for e.g. Chinese Pinyin Simplified and typed a bunch of keys, the composition mode would stay active for quite a while, not registering as “idle”.

This posed a problem in The Archive when this composition mode was active.

To check if that mode is active, a text view’s hasMarkedText().

The idle signal I am relying on lives in RxSwift land and worked like this: 1 second after the last user input or selection change, change the internal state to .idle.

To account for character composition to take longer, I had to prevent this .idle event from firing while hasMarkedText() returned true. While hasMarkedText() returns true, no other interaction with the text view is registered, so I couldn’t just ignore the .idle event – there wouldn’t be another one coming later – but had to delay it.

Why not just ignore the .idle event? After all, when users finish character composition, the text view content is changed as usual and 1 second later another .idle event would fire. But when users abort the composition, no such change event occurs. If I just drop the .idle event, then the user aborts character composition, the text view would just not begin to idle at all anymore.

Digging around in RxSwift extensions, Marin’s post about retry pointed me to RxSwiftExt’s implementation of retry from which I stole the implementation of a delay:

The use of Observable.just(()).delaySubscription(...) took some time to get used to. The immediate signal of () is just the hook to apply the actual delay. Delaying the subscription means whatever is actually happening when subscribing or flat-mapping to this observable sequence is delayed. We’re not subscribing to this sequence directly, so it affects the content of the .flatMapLatest block.

First, here’s how I use it:

let idlingAfterUserEdit = anyUserInteraction
    // Debouncing will wait for the duration to pass after
    // the latest edit, so we effectively have 1s idle delay.
    .debounce(.seconds(1), scheduler: MainScheduler.instance)
    // Delay event production while composition is active
    .flatMapLatest { _ in
        retry(until: { $0.hasMarkedText() == false },
              on: textView,
              delay: .seconds(2))
    }
    // Handle retry timeout: just begin to idle then.
    .catchAndReturn(())
    .map { _ in .idle }

Now here’s the retryUntil implementation:

/// Produces a success event `()` either right away if `test` passes,
/// or after N tries (up to `maxTries`) with `delay` between each try.
/// - Returns: Observable sequence producing a `()` signal once the
///   condition is met, or an error when it times out.
func retry<T>(
    until test: @escaping (T) -> Bool,
    on object: T,
    delay: RxTimeInterval,
    scheduler: SchedulerType = MainScheduler.instance,
    maxTries: UInt = .max)
-> Observable<Void> {
    return retry(
        until: test,
        on: object,
        delay: delay,
        scheduler: scheduler,
        maxTries: maxTries,
        currentTry: 0)
}

private func retry<T>(
    until test: @escaping (T) -> Bool,
    on object: T,
    delay: RxTimeInterval,
    scheduler: SchedulerType = MainScheduler.instance,
    maxTries: UInt,
    currentTry: UInt)
-> Observable<Void> {
    if currentTry > maxTries {
        return .error("retryUntil: \(currentTry) over limit")
    }
    if test(object) {
        return .just(())
    }
    return .just(())
        .delaySubscription(delay, scheduler: scheduler)
        .flatMapLatest {
            retry(until: test,
                  on: object,
                  delay: delay,
                  scheduler: scheduler,
                  maxTries: maxTries,
                  currentTry: currentTry + 1)
        }
}

I didn’t find value in taking the extra time to make this truly generalizable over any Observable<Element> sequence; I don’t actually care about the contents of the event that I want to delay since I’m mapping them to .idle anyway without even looking. But if you add that to your code base, please do share.

As always with RxSwift, I’m not feeling overly confident in the approach or in my ability to understand what’s going on, even if I extract well(?)-named helpers like this that don’t do much.

To inspect the call stack and see if letting this run UInt.max times would result in a stack overflow, I did what every good caveman does:

if currentTry > 10 { fatalError() }

Nope, looks good; the delay schedules the block itself on the target queue like DispatchQueue.main.asyncAfter(...) would.

The good news so far is that delaying the .idle event until after the comoposition mode has ended does wonders. Auto-saving of the contents still happens in the background, but the editor doesn’t abort composition and display external updates.

All of this, by the way, would’ve been so much simpler if the marked text API had some kind of notification or delegate callbacks. I pondered adding this myself, but the edge cases are too messy – e.g. you can’t rely on unmarkText() being called when the user aborts composition by clicking outside the app and bringing another window into focus.