Building address-bar autocomplete for a WKWebView app: pinyin, frecency, and my own undo stack
Overlay is a floating panel full of web apps, and every tab is a WKWebView. WebKit renders pages; everything around them — address bar,
history, suggestions — is yours to build. Until recently Overlay's address bar was a plain text field: you typed a URL or a search and that was it.
The case I kept running into myself: I'd read a Zhihu thread or a Feishu doc a few days earlier, I remembered a couple of Chinese words from the title,
and I had no idea what the URL was.
So the goal was narrow. Typing part of a Chinese title should find the page. So should typing its pinyin. Sites I use a lot should come first. This is the order I built it in, including the problems that a code review and a few rounds of testing on my own machine turned up afterwards.
Fixing the history first
The existing visit history couldn't support ranking at all.
It had been built for the "Recent" card on the new-tab page: 200 entries, oldest dropped first. The counts were worse. The tab's URL changing recorded a visit, and the title changing called the same function again, so every navigation counted twice — and single-page apps added one more each time they changed their title. Restoring tabs at launch reloaded each one and counted that too. My own history had a few GitHub Actions run pages with visit counts that made no sense.
The fixes were plain. Title updates only update the title. The reload from session restore doesn't count. The cap went from 200 to 3,000, and when it's full the least useful entry goes, not the oldest. I also started counting "typed" visits separately: a page you opened from the address bar or new-tab page says more about wanting to come back than one you reached by clicking a link.
Adding that field almost bit me. Visit was a compiler-synthesized Codable. Add a non-optional property and every existing user's history.json fails to
decode, gets renamed to .corrupt, and their history is gone. It's a tolerant decoder now — missing fields get defaults, a single bad entry is skipped.
The settings file had worked this way for a long time; history had been missed.
I didn't try to repair the inflated counts already on disk. Visit counts go through a log, so doubling them barely moves anything, and recency decay will wash them out.
Matching: normalize first
Input and candidates go through the same normalization: NFKC (full-width becomes half-width), lowercase, Traditional Chinese to Simplified.
git finds github, and 飛書 finds 飞书云文档.
Traditional-to-Simplified is ICU's Hant-Hans through Foundation's applyingTransform. The catch is highlighting: to bold the matched characters I need to know
where each normalized character came from in the original string. My first version normalized the whole string and assumed positions still lined up as long as
the total length hadn't changed. The review produced a counterexample where one character got longer and another got shorter, cancelling out, and the highlight
landed on the wrong characters. It's per-character now (with a fast path for ASCII); the Chinese conversion still runs on the whole string, and is only accepted
when the Unicode scalar count is unchanged.
Pinyin comes from the system's mandarinToLatin transform with tone marks stripped — no third-party library. It isn't cheap: building the index for 3,000
Chinese titles takes about 0.4 s in a Release build. So the index builds on a background thread, gets warmed up two seconds after launch, and after that only
rebuilds incrementally, reusing every entry whose URL and title haven't changed (about 20 ms). A query per keystroke takes 3–5 ms.
A few pinyin details. Characters with several readings get whatever the system picks — 银行 comes out as "yin xing", and I haven't done anything about it.
Early on, one character the system couldn't romanize threw away pinyin for the whole run of Chinese around it; now that character just splits the run.
And ü loses its dots when you strip tones and turns into u, so I map it to v instead: nv finds 女, the way pinyin input methods work.
Scoring
The input is split on spaces and every word has to match something. Each kind of match has a score: start of the domain 100, start of the title 80, Chinese text inside the title 60, pinyin from the start of the title 50, pinyin initials 45, then lower tiers for paths and mid-title pinyin. Word scores add up and get multiplied by frecency:
frecency = recency weight × (1 + ln(1+visits) + 1.5·ln(1+typed) + 1.5 if quick link + 0.5 if open in a tab)
Recency weight runs from 1.0 for today down to 0.15 for anything older than three months. History eviction uses the same score; if the two disagreed you'd get a page ranking first in suggestions on the same day it was evicted.
A single Latin letter only counts at the start of something, otherwise typing g would pull in every site with a g in its domain. A single Chinese character
has no such limit — one character is often a whole word.
One rule came out of the review: the last label of a domain doesn't count. Typing com, ai, or even a lone c used to promote the .com or .ai site I
visit most to the first row as a "subdomain match", and Enter went straight there. The last label is excluded now, along with common second-level suffixes like
com.cn and co.uk. I didn't bundle the full Public Suffix List for this — there's a short built-in list, and missing an obscure suffix just means that label
gets treated as an ordinary subdomain.
The first row is what Enter does
There was a choice here. Chrome is conservative: apart from inline autocomplete, Enter mostly searches. Safari has a "Top Hit" that can put a strong history match first. I went with the Safari approach, because the case I care about most is typing 知乎 and landing on Zhihu, not on a Google search for "知乎".
The cost is that the bar has to be high. Every word needs a strong match, and the page has to have three or more visits or two typed visits. Otherwise the first row falls back to search. Sending someone somewhere they didn't mean to go is a lot worse than making them press ↓ once. This rule got tightened twice more during testing; more on that below.
Inline completion (typing git fills in github.com) only happens for ASCII input, and only for pages visited at least twice. Where it completes to matters too.
If you've been to the site's front page, it completes to that. If not, and you only ever visit one page there, it completes to that page (fei becomes
feishu.cn/docs). If you visit several pages there, it completes the domain only. My first version completed to "the page you visit most", and on my own
machine git filled in a CI run URL dozens of characters long.
The list can't be a SwiftUI overlay
Overlay's toolbar is an NSHostingView, and the web content below it is a separate view — two view trees. A SwiftUI overlay can't draw past the toolbar's
bounds, so the list would be clipped. It's a child window instead: a non-activating NSPanel with becomesKeyOnlyIfNeeded, attached with
addChildWindow(_:ordered: .above). I'd already built the password autofill dropdown this way.
Focus has to stay in the address field, so the list never gets keyboard events. ↑↓, ⇥, ⏎ and ⎋ are intercepted in the text field's
control(_:textView:doCommandBy:) and forwarded to a controller. While an input method is composing (pinyin with an underline in the field) nothing happens:
no edit is reported, no keys are forwarded, no completion is written into the field. Changing the field's text mid-composition breaks the input method; the
notes editor taught me that one already.
Small trap: the list re-sorts on every keystroke, so the row that slides under a stationary mouse pointer gets a mouseEntered, steals the highlight,
and Enter opens a row you never chose. Hover only counts now once the mouse has actually moved.
What review and real-machine testing found
When the feature was done I had three reviewers go through the code in parallel, then ran a few rounds of tests against my own real history. No crashes, but several ways for Enter to go to the wrong place.
The worst one: on Enter I passed the highlighted row's URL to navigation. The original navigation path's first step checks whether the input is an
extension's address-bar keyword (Chrome extensions' omnibox: "keyword, space, query"). Since the list always has a search row, the list always handled
Enter and passed down a Google search URL, which never matches a keyword. Search rows and "open this URL" rows now hand over exactly what you typed.
Second: searches were counted as typed visits. Search for "swift concurrency" once and that results page qualified as a Top Hit, so typing swift and pressing
Enter reopened the old search. Searches no longer count as typed, and search result pages are never Top Hits or inline completions.
Third, one I hadn't thought of: pasting. Paste github.com/apple, press Enter, and it got completed to the more-visited github.com/apple/swift.
Completion now only happens when exactly one character was added at the end. Pasting, cutting, and inserting in the middle don't trigger it. The controller
decides by comparing the new text with what it last saw, not by looking at which key was pressed — ⌘X, paste, drag-and-drop and undo all get around key names.
Even with completion off, the Top Hit could still promote the longer URL, so testing added one more rule: when the input itself looks like a URL, the Top Hit
has to be exactly that URL.
Then deleting entries. I started with ⇧⌫ to remove the highlighted row from history. The review pointed out that holding Shift to type a capital and hitting
backspace would permanently delete whatever was in the first row. I switched to ⇧⌦, as Chrome does (fn+⇧+⌫ on a laptop). Unit tests passed. On the real
machine, pressing it did nothing. The system's default key bindings have no entry for Shift + forward delete; the text system treats it as inserting a
function-key character and drops it, so it never becomes a command and doCommandBy never sees it. The fix is a local key-down monitor installed only while
the field is editing, which catches the key before it reaches the text system.
⌘Z was another real-machine find. Completion text is written during the same event as your keystroke, so the system's undo groups "the character you typed plus
the completion" into one step. Each ⌘Z then walked back to some earlier completion: after exa came ex plus ample.com, then e plus an unrelated domain,
and the controller's idea of what you'd typed no longer matched the field. The field now turns off the field editor's own undo while editing and keeps a list of
what you actually typed; ⌘Z goes back one entry.
When does a visit count?
Visits used to be recorded from KVO on webView.url. That property changes during the provisional stage of a navigation, so download links and the
intermediate hops of a redirect were all counted as visits.
I wrote a bare WKWebView test program to print the callback order for different kinds of navigation. For a new navigation, a server redirect, a download and a
DNS failure, isLoading is already true at the moment url changes. Redirect hops never commit (didCommit). Anchor links and pushState change url while
isLoading is false. After a cancelled download or a failed load, url reverts to the previous page — also while not loading.
So visits are now recorded when the main frame commits. A url change that happens while not loading means the page changed its own URL, and is recorded
on the spot. Each tab also remembers which page it last recorded; if the same page shows up again (anchor, reload, www. redirect, the revert after a
download or failure) only the title is updated. New documents are recorded with an empty title, and titles are only written to the document that's actually on
screen. Before, a new entry took the tab's previous title, so opening the New York Times from a Bilibili tab would have produced an entry titled "Bilibili".
One site flooding the list
After a few days of use, typing git listed six or seven nearly identical GitHub URLs. Chrome rarely does this. My impression from reading Chromium's code is
that it gives history results only a few slots and spends most of the list on search suggestions, and that it only indexes URLs you've typed, visited several
times, or visited in the last few days. Overlay doesn't do search suggestions (that would mean sending every keystroke to a search engine), so history results
are most of the list and the problem shows more.
Now a site gets at most 3 rows, unless the input contains a / — then you're looking for a specific page. History results are capped at 7. A page visited only
once, never typed, and last seen more than three days ago has its frecency cut to 30%, and doesn't appear at all if it only matched by domain or path. If its title
or pinyin matches it still shows up; finding a page you saw once by part of its Chinese title is the reason this feature exists.
What's not there
No search suggestions, for the reason above. No full history page, no importing history from Safari or Chrome, and search is still hard-wired to Google. If years of your browsing live in Chrome, Chrome's address bar simply has far more to work with than Overlay does. Overlay's address bar is for the handful of web tools you open over and over in a floating panel, so they're back in two or three keystrokes.