I put 5,000 invisible characters in a text field so backspace would work
Four fights with iOS input, and the absurd tricks that won them: ghost characters, finger slots, and a gesture war.
My app shows a live terminal on the iPhone (the session actually runs on a Mac). I held backspace to clear a long command. One character vanished, then nothing. The keyboard just sat there, repeating into the void.
That was fight one of four. Each one ended with a trick I'd have called ugly a month earlier, and each trick is now load-bearing. This article is the four of them, with the reasoning, because none of it is documented anywhere and all of it applies to any app that isn't a form.
01 · PRIOR ARTWhat already works, honestly
- Hardware keyboards are fine. UIKit gives you key events for physical keys, with modifiers and repeat. If your users all have a Magic Keyboard, stop reading, you don't have this problem.
- The classic trick exists for one backspace. The old Stack Overflow answer: put a zero-width space (an invisible character) in a text field so you can detect backspace on an "empty" field once. My whole approach is that hack, industrialized.
- UTM showed the layout pattern. Pinning the accessory bar (the button row docked above the keyboard) to iOS's keyboard layout guide comes straight from their virtual machine app. Credit where due, I copied it.
What none of that covers, and what this article is about: the software keyboard, held keys, more than one finger, and other people's gestures.
02 · THE GHOST FIELDThe ghost field
The actual problem: on iOS, the software keyboard doesn't send you key events. It edits text fields. "Backspace held down" doesn't exist as an event you can receive; what exists is "a text field with content keeps shrinking". No content, no shrinking, no repeat. A terminal has no text field, so a held backspace is one delete and then silence.
So I gave iOS what it wants: a hidden text field (invisible, no caret, never on screen) that holds the keyboard focus, pre-filled with 5,000 spaces. The user holds backspace, iOS happily deletes ghost spaces one by one, and each delete fires a callback. In the callback I forward a real delete character (0x7F, what terminals expect) to the shell. iOS thinks it's editing a document. It's actually driving a terminal.
The delegate (the object iOS asks before every edit) has a beautiful asymmetry that took me a while to get right:
- Backspace (the callback's replacement string is empty): forward the delete, and return true, so the field really shrinks. The shrinking is what convinces iOS to keep firing the auto-repeat.
- Typed text: forward it to the shell, and return false, so it never enters the field. The field stays a pure backspace reservoir.
Then the details that each cost a day:
- Refill, but never during a burst. Below 200 remaining ghosts the field gets topped back up to 5,000. But refilling while the user is still holding backspace kills the repeat: iOS decides whether to fire the next tick by checking that the field actually shrank, and a refill makes it grow. So there's a 0.2 second quiet period after the last delete before any refill.
- Return closes the keyboard. Seriously. UITextField treats Return as "submit the form": it fires its end-of-editing path and resigns the keyboard before the delegate ever sees the newline. In a terminal that means Enter dismisses the keyboard instead of running your command. The fix is intercepting Return, sending a carriage return (0x0D) to the shell myself, and refusing the default so the keyboard stays up.
- A blanket "no menu" swallowed paste. The field holds focus, so it also receives every edit-menu query. My first version answered no to everything, clean, and ⌘V silently died for the entire terminal. Now it answers yes to exactly one action, paste, and routes the pasted text to the shell instead of into the ghosts.
- Every reappearance re-inflates the field to 5,000, so a session you come back to behaves like a freshly opened one.
What this buys, for free, because iOS believes it's a real document: long-press backspace repeat, hardware key chords, and dictation. All of it lands in the callback and flows to the shell.
03 · TWO FINGERSSwiftUI drops your second finger
Fight two. The mirrored screen supports pinch to zoom. I built it with SwiftUI's DragGesture. Pinch: nothing, and no error or warning either.
DragGesture is single-touch. When a second finger lands, it just isn't delivered. There's no multi-touch variant to reach for; if fingers matter, you leave SwiftUI's gesture layer and override the UIKit touch methods yourself.
And one detail matters more than the override: identity. My touches cross a network (the tap happens on the iPhone, the touch target runs on the Mac), and a pinch is only a pinch if the receiver can tell finger A from finger B across every move event. So each finger gets a slot: the lowest free number from 0 to 4 on touch-down, kept for the touch's entire lifetime, released on lift. The Mac maps each slot to a stable contact point, and a pinch survives the wire as a pinch.
The gesture war
Fight three, and the strangest one: the enemies were my own navigation transitions.
I used iOS's zoom transition for opening the terminal (the card grows out of the row you tapped, it's lovely). What nobody tells you: that transition installs its own pan and pinch recognizers (the objects that watch touches for a gesture) on the pushed screen, so that a drag anywhere can start the interactive shrink-to-dismiss. In a terminal, "a drag anywhere" is called scrolling. Every scroll started dismissing the screen, and the mid-flight scaling triggered grid resizes the shell then had to chase.
The fix is a 67-line class whose whole job is diplomacy: while the terminal is on screen, it walks up the view hierarchy, finds the transition's recognizers, and switches them off, then re-enables them on the way out so every other screen keeps its behavior. The zoom-in animation stays; the "any drag dismisses" behavior goes.
Same war, different front: swipe-to-reply in the chat took six commits to coexist with everything else that wanted the same finger. It had to not fight vertical scrolling, cede the leading edge to the system back gesture, survive gesture cancellation without a stuck icon, and lock to one direction with a haptic once committed. None of that is the swipe itself; all of it is negotiating with neighbors.
05 · TAPPABLE OUTPUTMaking the output tappable
Half of input is output: the screen has to tell your finger what it can do.
Terminal text with a URL in it looks tappable to nobody. SwiftTerm (the terminal engine I embed) does underline links, but only on hover, and hover doesn't exist on a phone. So tappable URLs carried no visual hint at all. I draw the underlines myself in an overlay layer, scanning the visible rows for URLs. The trap took a day to see: full-screen programs repaint their text in place, no scrolling involved, so refreshing the underlines on layout changes left them stale under new text. The scan has to re-run after every burst of bytes, not just when the view moves.
And the tap itself joined the gesture war. Tapping a link opens it, but double-tap is how you select a word to copy, and the first tap of a double-tap is indistinguishable from a link tap. My first version opened Safari and backgrounded the whole app on tap one of every copy gesture. The fix is one ordering rule: the link tap must wait until the double-tap has officially failed before it fires.
06 · FOCUSWho owns the keyboard
Fight four is invisible until you have two terminals.
My tabs stay mounted when you switch away (cheaper than rebuilding), which means a terminal you left three tabs ago still exists, still has a window, and still hears keyboard notifications. My reclaim logic used to check "does the hidden field have a window?", which was always yes, so a background terminal would answer a keyboard event from the chat tab and quietly steal the keystrokes. The way I found it: I was typing a message in the chat, and the letters were landing in a shell three tabs away. Two open terminals would fight over the keyboard like cats.
The rule that ended it: only the surface actually on screen may claim the keyboard, and going active never grabs it back automatically, because that would be the same theft in the other direction. The user taps to type. And when a modal covers the terminal, the reclaim stands down entirely until the modal is gone.
One deliberate loss: text selection parks the keyboard focus on the terminal view itself. When you switch tabs mid-selection, I drop the selection on purpose, because coming back to a stale selection toolbar over text you no longer remember selecting is worse.
07 · STILL BROKENStill broken
- The custom key panel sizes itself to the last keyboard height iOS showed me. There's no API for "how tall is the keyboard right now", so a future keyboard layout change will misalign it until first use.
- The ghost field is a trap, not an API. Every OS update could change how auto-repeat decides to continue, and I'd find out from users, not release notes.
- Selection still lives on the terminal view, not the hidden field. It works, but focus hopping between two views during selection is the part I trust least.
Three things I'd tell someone starting
- iOS gives you widgets, not input. If you need raw input, you don't receive it, you bait a widget into translating it for you. The ghost field is bait.
- When fingers matter, leave SwiftUI's gesture layer. DragGesture's single-touch limit fails silently, and silent is the expensive kind of failure. The UIKit touch overrides are less pretty and entirely honest.
- A gesture recognizer is never local. Every screen-level feature you adopt ships recognizers that claim your pixels, and your own features compete with each other too. Budget for the diplomacy, it cost me more commits than the features.
The terminal these tricks serve, and the state problem behind it, is the previous article. Next one: how a subscription can follow an account across three devices without the server ever seeing your name.