SourceEditor-backed content view (0009), and UXKit → AppKitPlus (0013) - #107
Merged
Conversation
Xcode's SourceEditor family ships no module interface, no headers, and next to
no Objective-C surface, so calling it needs a hand-written `.swiftinterface`.
The frameworks enable library evolution, which makes that viable: the compiler
builds the interface into its module cache on demand, and callers reach members
through resilient dispatch rather than a hardcoded layout.
Nothing Apple-authored is checked in. Each stub is two text files -- a `.tbd`
symbol list produced by `tapi stubify`, trimmed to the 42 + 4 symbols actually
referenced (976K -> 4K), and the interface subset itself.
Two rules decide how each member must be declared, and both are mechanical, so
`AuditMembers.sh` answers them from the binary instead of leaving them to be
discovered one link error at a time:
- A member exported behind a dispatch thunk is declared plainly; one exported
only as a direct symbol must be declared `final`. Guessing wrong fails with
`Undefined symbols: dispatch thunk of ...`.
- Protocol requirement order must match the witness table. RuntimeViewer's own
dumps print PWT offsets, which run 0x8, 0x10, 0x18 in declaration order -- a
gap in that sequence means a requirement is missing, which is a free
completeness check.
Resilient enums are ordered too, so `SourceEditorTokenType` is declared with no
cases at all: its values are only passed through, never matched on.
Puts Xcode's own editor behind the content pane, so it gains code folding, sticky headers, a find bar, scope guides and Cmd-hover underlining without any of them being written here -- and, on a 5MB interface, a worst frame of 9.43ms against TextKit 2's 25.08ms. The app links none of it. A link-time dependency on frameworks that live inside Xcode would abort in dyld before `main` on a machine without Xcode, so the only code referencing those symbols sits in a loadable bundle, mirroring the existing RuntimeViewerCatalystHelperPlugin arrangement. `SourceEditorLoader` dlopens the frameworks by absolute path first; dyld then reuses those images by install name when the bundle's own `@rpath` dependencies resolve. Loading sweeps to a fixed point rather than following a topological sort. The frameworks depend on each other -- SymbolCacheSupport needs SymbolCacheIndexing -- so one whose dependencies are not up yet fails a pass and succeeds a later one. That keeps a future Xcode reshuffling the graph from breaking loading. Frameworks are looked for in the app's own Frameworks directory before Xcode's, which reduces whether to ship them to a packaging decision rather than a code path. Only four are needed; the SymbolCache trio serves indexing and completion. `ContentSourceEditorViewController` binds the same `ContentTextViewModel` as `ContentTextViewController`, so choosing between them is the whole of the switch and every failure -- no Xcode, an arm64e build, a missing bundle -- lands on the existing implementation. Cmd-click arrives through `SourceEditorViewEventConsumer`, not through `codeNavigationHandler`: cross-references show the latter is read only by the framework's own Vim command handlers. The bridge reports the clicked token's UTF-16 range and resolution stays here, against the `.link` attributes the generator produced, so jump targets keep their semantic precision even though the framework colors the text lexically. Kept opt-in behind `RuntimeViewerUsesExperimentalSourceEditor` for exactly that reason. Syntax coloring is currently the framework's lexical guess rather than the generator's semantic tokens, and the app's configured theme is ignored in favour of the one Xcode ships. Both are tracked in proposal 0009; until they are closed, opting in would trade a better editor for worse highlighting.
…ce Editor Replaces the hidden `RuntimeViewerUsesExperimentalSourceEditor` default with a real setting, and makes the Xcode-backed content view honour the configured theme instead of falling back to the one Xcode ships. `SourceEditorThemeConversion` overwrites a copy of the framework's own theme rather than building one from nothing: the format has around fifty keys — console colors, markup fonts, scrollbar markers — and most have no counterpart in `ThemeProfile`, so starting from a complete dictionary keeps the unmapped ones working and leaves room for Xcode to add more. Color components go in as generic (calibrated) RGB, not sRGB. The framework parses `"r g b a"` with the equivalent of `NSColor(calibratedRed:...)`, so the same triple read as sRGB renders 0.420/0.886/0.459 where calibrated renders 0.404/0.886/0.518 — a visible difference, and a silent one. `SemanticType` maps onto Xcode's syntax categories as a table rather than a switch, because injecting semantic tokens will need the same mapping in the other direction. The directory search moves to `XcodeSourceEditorLocator` in RuntimeViewerSettings so the settings pane and the loader agree on what "installed" means; the pane disables the toggle and says so when no Xcode is found, and states plainly that syntax coloring is currently less accurate than the built-in view's. `ContentCoordinator` now tracks which content view is in use, so flipping the setting swaps it on the next selection instead of only after the scene has been left and re-entered — which from the user's side looked like nothing happened. Also adds `com.apple.security.cs.disable-library-validation`. It may well be unnecessary, since library validation permits Apple-signed code and these frameworks are Apple-signed, but that cannot be confirmed on a machine with library validation switched off and the failure mode would be the editor silently never loading.
…oring Three things were wrong once the editor was actually looked at in the app. **Type names were not colored.** The framework tokenizes plain text lexically, so `NSString` is just an identifier to it — while RuntimeViewer knows exactly what every identifier is, having rendered the interface from runtime metadata. Rather than supply a whole `SourceEditorLanguageService`, which is a 48-requirement protocol, the generator's colors now go in through `TextAttributeOverrideProvider`: two requirements, one of which has a default, and `SourceEditorTextAttributeOverride` takes `NSAttributedString` attributes directly, so no mapping onto the framework's token types is needed. Registration is `SourceEditorLayoutManager.addLayoutOverrideProvider(_:)`, which sorts a provider into its buckets by dynamic cast — the route the framework's own `SourceEditorLineAnnotationManager` takes. Column ranges are clipped to each line's *content* length. Running past the end of a line is not ignored; the framework traps on it. **Line numbers were missing** because the view ships without a gutter at all. One is installed and `enableLineNumbers()` called. **The background ignored the theme** past the text area. The theme dictionary covers what the editor paints behind text; the view's own `backgroundColor` and the container behind it are separate, and both now follow the theme. The current-line highlight is derived from the background, since `ThemeProfile` has no entry for it and keeping Xcode's would band a differently-colored editor. Finding all this cost an afternoon to one mis-declaration, now documented as the third reconstruction rule: `SourceEditorGutter` is `NSObject`-rooted, and declaring it as its own root made it construct fine, answer every call fine, and crash on *deallocation* — Swift emits native release for a root class it thinks it owns. An instance kept alive for the process's lifetime hides it completely, which is how a spike passed with the bug in it. `AuditClasses.sh` answers the question from the binary. `@objc deinit` is emphatically not the signal: Darwin exposes every Swift deinit as `dealloc`, real interfaces carry it on classes that inherit nothing, and adding it to a wrongly-rooted class was measured not to help. `LayoutOverrideProviderPriority` is read off the gutter rather than constructed. Its case order is unrecoverable from the exports, and for a resilient enum both case indices and implicit raw values come from that order — so naming a case would compile and mean something else.
RuntimeViewer's own per-type export of the framework states outright what the symbol table only hints at: superclasses, enum cases in declaration order, protocol requirements with their witness-table offsets, struct layouts, full initializer signatures. Reconstructing a declaration should start there. Two conclusions reached the hard way were simply wrong, and the dump has both on its first line. `SourceEditorGutter` is `NSObject`-rooted — declaring it as its own root cost an afternoon to a crash that only fires on deallocation. And `LayoutOverrideProviderPriority`'s case order was called unrecoverable, which sent the priority off to be borrowed from another object rather than written as `.high`; the dump prints `low` / `medium` / `high`. The audit scripts stay as the fallback for when no dump is at hand, and the version skew is worth remembering: the dump is from Xcode 26.5.0 while the stubs and the runtime load target 26.6.
…icLanguageService cannot work Its conformances are witnessed by protocol-extension defaults, which are statically dispatched, so a subclass's overrides are never called — only indentLine has a vtable slot. Supplying tokens needs a conformance of our own. That turns out to be far cheaper than estimated, and the dump says so directly: of SourceEditorLanguageService's 48 requirements, 46 have defaults, leaving init(language:buffer:) and indentLine. GenericLanguage is a concrete class whose init takes a languageService type, so no SourceEditorLanguage implementation is needed either, and SourceEditorTokenData.uiKind carries the class/type distinction along with project-versus-SDK scope. The cost is the surrounding surface — the protocol's 48 signatures plus 46 defaults, a dozen opaque types, and our own token data — not the two methods. Left unbuilt: the visible win is already in via the override provider, and what remains is command-click precision and one avoided traversal.
Replaces the text-attribute overrides with the framework's own extension point: a language service hands every parsed node to its `nodeTypeAdjuster` before use, which is how Xcode upgrades a lexical parse with index data. Rewriting node types there means the editor's own machinery becomes semantic, so `tokenRangeAtPosition` — which is what ⌘-click reads — improves along with the colours, instead of correct colours sitting on top of an incorrect parse. Node type names turn out to *be* the theme's syntax keys, and `SMSourceNodeTypes` is a registry keyed by name, so ids are resolved by name at runtime rather than hardcoded; they are handed out in registration order and shift between versions. Reaching the service takes no subclassing and none of the 48-requirement language service protocol: `SourceEditorDataSource.languageService` downcasts to the concrete `SourceModelLanguageService`, and the two Objective-C classes involved are stubbed with a header and a module map. Subclassing `GenericLanguageService` cannot work for this, for the record. Its conformances are witnessed by protocol-extension defaults, which are statically dispatched, so an override is never called; only `indentLine` has a vtable slot. A node is rewritten only when one semantic run contains all of it. Parser nodes and generator runs disagree on boundaries — in an Objective-C method declaration one node covers the parameter type *and* the name after it — and matching on the node's opening character alone coloured those names as types. The theme conversion now writes all twenty-eight syntax keys. Writing only some left the rest on Xcode's own colours, so SDK classes came out purple while classes from the inspected image followed the configured theme. Since the app's theme resolves everything to seven text styles, the mapping is many-to-one and each style owns exactly one key for assignment; grouping follows `ResolvedTheme.style(for:)`, which is what `.variable` and `.member(.name)` landing on the same key had broken — the later write won and property names took the type colour. Verified on screen. Worth remembering that the offscreen harness called this broken three times while it was working: a cached bitmap goes through a different render path, so it can show a mechanism is connected but not whether a colour is right.
The proposal had accumulated a running commentary rather than a state anyone could act on after stepping away. Nine items are finished and verified on screen; seven are not, and each now says what it needs rather than that it is pending — the memory measurement that was only ever eyeballed, the three install-time capabilities whose entry points are already located, the theme model change that colouring SDK symbols separately would require, notarization, a settings string that the semantic-colouring work made untrue, the unpushed branch, and the companion write-up. The first semantic-colouring attempt is kept but marked superseded. It produced correct colours over an incorrect parse, which is worth remembering as a thing that looks finished and is not.
It was derived from the background — brightness test, then plus or minus 0.05 — because the theme had nowhere to put it. That guessed at something the user can now just pick: `Settings.Theme.Preset` gains a `currentLineHighlight` slot, carried through `ThemeProfile` and `ResolvedTheme`, editable as "Current Line" in the theme settings. The slot carries a default where the other colour slots do not. Presets saved before it existed have no such key, and a required slot would make every stored custom preset fail to decode. The default is Xcode's own pair, which sits a hair off its matching background rather than reading as a band. Only the Xcode-backed content view draws it; the built-in `NSTextView` has no current-line highlight, which is noted on the protocol requirement so the next reader does not go looking for where it is painted.
The spike's "2.8x TextKit 2" was an artefact of measuring in an offscreen window where nothing is ever under pressure to reclaim, and both figures were unusable. Attributed on a running app with the editor in use, SourceEditor and SourceModel together account for 2.0 MB across 19978 objects — 0.3% of the heap. It keeps layers for visible lines only, and the parser's rule tables are one-off. Adopting the editor costs nothing in memory. The process total is the app's own model data and is left out of the conclusion, since the work on that is on another branch.
Code folding, sticky headers, the minimap, line numbers and scope guides are each a switch in Settings › Editor. Every one of them is a pair of no-argument methods on SourceEditorView that builds what it needs from the view's own state, so nothing has to be configured first; folding additionally needs a language service that can name foldable ranges, which SourceModelLanguageService already conforms to. The install half is not idempotent — installMinimap() skips building a second minimap but goes on to register it as a margin accessory and an event consumer again — so the bridge records what it last applied and only calls a side on an actual transition. The uninstall half is idempotent, and so are show/hideScopeGuides(), which is why scope guides alone need no state tracking. Verified structurally rather than visually: a harness walks the view and layer tree over eight enable/disable cycles and counts the class that exists once per installation. That is what offscreen rendering is reliable for — a class is in the tree or it is not — unlike the colour conclusions it got wrong three times. It also turned up a framework-side leak: re-installing the minimap leaves eight highlight layers behind each time, strictly linear over eight cycles. Recorded, not worked around. Two things fixed alongside: - The Settings pane claimed syntax coloring came from Xcode's tokenizer and was less accurate than the built-in view. That stopped being true when coloring moved to the node-type adjuster. - The committed .tbd stubs were the full 1.1 MB ones, not the 8 KB trimmed output — a half-finished refresh from the previous batch. Regenerated, and both READMEs now say to check for it.
Line numbers never appeared, and the symptom did not look like a missing font: the gutter collapsed to 6pt — just the divider — and all 144 line number layers measured 0x0. enableLineNumbers() only writes a Bool and refreshes. The size of each number comes from SourceEditorGutterMarginContentView.layerSizeForDigits, which lays "000…" out in a text layer using a font stored on the content view and reads the result back. That font starts nil and its only entry point is the gutter's lineNumberFont setter, so every measurement came back zero and the margin had nothing to be wide for. No theme key covers it — the .xccolortheme format has no gutter entry at all — so applyTheme now carries the font. The app passes the NSFont it already resolved rather than having the bridge re-parse "SFMono-Regular - 12.0" and risk disagreeing with the framework's own parser. Setting the font also requires calling enableLineNumbers() again: the setter drops the cached digit sizes but schedules no redisplay, and the private method that does is not exported. Measured before and after: margin 6pt -> 36pt, layers 0x0 -> 8x15 with contents. Also recorded, having chased it to the instruction: a sticky header is one line by design, never wrapped. headerLineLayer passes nil as the width to layoutAndSizeToWidth, and StickyHeaderViewContents holds a single line layer. The maxWidth it does take drives the fade on the overflowing tail instead. Xcode behaves the same way, so this is left alone.
The content pane pins its top to the superview rather than the safe area so text scrolls under the toolbar. With NSTextView that was enough: the scroll view was the pane's own view and AppKit derived its content insets from the safe area. SourceEditorView is a plain view with the scroll view buried inside it, and installScrollView() switches automaticallyAdjustsContentInsets off and drives the insets itself — so the safe area never reached it and the first lines, along with the sticky header, were painted over the toolbar. viewDidLayout now hands the editor view's own safeAreaInsets.top to additionalScrollViewContentInsets.top, which is the framework's documented way in. Re-read every layout rather than stored: it moves with the toolbar, full screen and window chrome, none of which reaches the content pane as a notification. Measured in a full-size-content window with a 66pt safe area: scroll view top inset 0 -> 66, sticky header window y 582 -> 516.
…ditor The editor view reads a zero top safe area in the real hierarchy: AppKit resolves the safe area against the controller's view and does not re-derive it for a descendant that extends past it. Asking the editor view directly returns the toolbar height only when it is a direct subview of the window's content view — which is how the harness that verified this was built, and is not how the pane is built. The harness measured 66 while the app measured 0.
…visibles Two changes, one of which explains the other. applyTopContentInset was writing additionalScrollViewContentInsets. That is the find panel's own channel: presenting it writes the panel's height straight into additional.top, which threw away the toolbar offset stored there and left the panel flush against the window's top edge. The scroll view's insets are the sum of `default` and `additional`, so `default` is the field a host is meant to use. Measured with a 66pt safe area and a 28pt panel: additional: 66 before ⌘F, 28 after (ours gone), panel at y=0 default: 66 before ⌘F, 94 after (66+28), panel at y=66 This corrects an earlier reading of the disassembly, which had it that the two were summed and therefore independent. They are summed; they are not independent. Also adds an Invisibles switch. showInvisibles() needs no setup, unlike the gutter's font — it reads the view's current colorTheme and casts it to the framework's ShowInvisiblesTheme, which SourceEditorTheme conforms to, and the colour comes from a theme key the conversion already leaves untouched. It does copy the theme at call time, so applyTheme re-runs it when invisibles are on, or the glyphs keep the previous appearance's colour. No work was needed to get a find bar at all: the framework registers the standard performFindPanelAction: path, so ⌘F already worked.
Draws a rule at every `// MARK:` line. Nothing to draw yet — the interface generator emits no MARK comments — but the switch defaults on so the separators appear on their own once it starts, rather than needing the setting changed a second time. Same shape as the scope guides: both halves write the same flag on the same lazily-created controller and ask the layout manager for a pass, so it is idempotent and needs no state tracking.
Draft. The open question is the rendering path, and it decides more than effort: whether the feature requires Xcode at all. Recommends laying out the attributed string the generator already produces, rather than SourceEditorView's snapshot API. Two reasons. It works for everyone, including the built-in text view and machines without Xcode — an export action should not exist only behind an opt-in switch. And editorViewSnapshots turns out to be cacheDisplay underneath, which is the same offscreen path this project already has on record as unreliable; the minimap's Metal layers come out blank through it. Registered in both indexes. "Snapshot" rather than "image": the existing export flow already uses "Image" for Mach-O images.
The goal moved from "integrate SourceEditor" to "reimplement it", and that needs a direction fixed before the proposals start, or every one of them re-argues the same three questions: keep depending on Xcode, copy the framework's layering, and where the semantics come from. Six tradeoffs are named and taken. The load-bearing ones: Copy the architecture, not the API. Reverse engineering produces understanding; Stubs/ is a reference, not an SDK to finish transcribing. Semantics come from the generator, not a parser. This is the one structural advantage over Xcode and it explains the only measurement SourceEditor loses — 384ms to load 5MB, because it tokenizes and colors everything up front. We already know what every identifier is. Explicitly given up in exchange: this engine will never display arbitrary files in arbitrary languages. Per-line layers, and geometry expressed as effects layered over immutable text rather than by rewriting it. Read-only plus immutable text is what removes the journal and managed-range machinery — most of the framework. The layout manager's nine provider arrays are quoted verbatim: that is the whole extensibility story in one object, and it is what makes the code worth copying. One tradeoff is left open rather than assumed — read-only-specific versus a reusable library — because it changes the shape of the first stage's API. Recorded under 待确认. 0009 and 0010 now back-link to it.
The ribbon sat flush against the first character. Its width is `leadingInset + 7`, and on macOS 26 that 4pt inset is spent on the side facing the line numbers, leaving nothing on the side facing the text. Xcode used to make up the difference through `SourceEditorContentView.additionalLeftPadding`, filled with `round(pointSize / 2)`. Disassembling `updateAdditionalLeftPadding()` shows macOS 26 gated that on a gutter annotation being present, so for a view with no annotations — ours — it is now always zero. Write `contentMargins.left` instead: same offset into the text's origin, and nothing in the framework writes it. Keeping Xcode's formula makes the gap track the font size. It has to be assigned ahead of the theme, since writing it does not invalidate the laid-out lines and the theme assignment is what flushes it. Measured through the shipping bridge: gap 0 -> 6 at 12pt, 12 at 24pt, and it survives a reload.
Opening the first runtime object stalled; every one after it did not. Timed per stage: the four `dlopen`s cost ~60 ms, the bridge bundle ~1 ms, building the editor view ~9 ms and the first frame ~12 ms — all one-time — while `setSource` costs 8-9 ms whether the parser is cold or warm. So the stall is the load, not a parser warming up, and it lands on whichever click first evaluates the controller's `lazy var bridge`. The loading half moves off the main thread cleanly: measured on a background queue it takes the same 63.5 ms while the main thread keeps running its runloop, and building the bridge afterwards still takes 10 ms. `dlopen` and `NSBundle` are both thread-safe; only the `NSView` stays here. No locking. A document opened mid-prewarm takes the synchronous path and at worst waits on the same idempotent work; whichever finishes first wins and the other does not overwrite it.
The first version lost it. A Time Profiler trace of the first selection shows the whole 81 ms `dlopen` on the main thread, with no worker thread doing the same work — so the prewarm had not reached `dlopen` yet when the user clicked. Launch is the busiest stretch the app has, and `Task.detached(priority: .utility)` scheduled at the end of `applicationDidFinishLaunching` gets queued behind all of it. Dispatch at `userInitiated` instead, and call it right after the settings load it depends on. Losing the race was never a correctness problem — the click did the work itself, as before — it just made the prewarm pointless. Log which path resolved the state: nothing else in the app's behaviour distinguishes a prewarmed launch from an on-demand one. The trace also prices the rest of that click: `makeBridge()` 7 ms, setupBindings + viewDidLoad 12 ms, the transition 13 ms, the inspector 8 ms, and 55 ms of CA commit. Even a prewarm that always wins leaves ~100 ms; going further would mean prebuilding the bridge and its first frame, which is a much smaller return.
The prewarm had never run. `SettingsLifecycleController.loadOnLaunch()`
is `Task { await settings.load() }`, and the prewarm read
`settings.editor.usesSourceEditor` synchronously right after it — so it
read the default, the master switch defaults to off, and the guard
returned before loading anything.
The previous commit misread the same trace as a scheduling problem.
Raising the priority was right on its own terms, but it was not the
cause; the evidence that separates the two is in the trace already — a
prewarm that merely started late would still show its own `dlopen` on
some worker thread, and there was none.
Observe the setting instead, via `SwiftNavigation.observe`. `SettingsStore`
is `@Observable` and `value` is tracked, so replacing the model at the end
of `load()` re-runs it. It also covers switching the editor on in Settings,
which now prewarms immediately rather than at the next launch.
…the shader compile `installMinimap()` reaches `MinimapMetalLinesLayer.init`, whose `sharedPipelineState` is a `static let`. The first minimap in the process therefore compiles its Metal render pipeline, and a cold-launch `sample` put that at 137 ms of the 185 ms the first runtime object spent building the editor — 130 of them with the main thread *blocked* on `com.apple.MTLCompilerConnectionQueue` rather than doing work. That is why the first selection felt slow while every later one did not, and why the existing framework prewarm did not help: no `dlopen` appears on that path at all. `SourceEditorLoader.prewarmBridge()` now builds one bridge as soon as the frameworks resolve and applies the current Settings > Editor display options to it, which is what triggers the compile. Applying the real options is load-bearing: with the minimap off nothing here runs, and nothing runs at the click either. The bridge is handed to the first document rather than discarded, so that document also skips the ~22 ms of view construction. The cost is moved, not removed — `SourceEditorView` is an `NSView`, so this is a main-thread stall of similar size, now landing during launch where the main thread is otherwise ~80% idle. Verified by a second sample on the same machine: the whole `ContentSourceEditorViewController` lifecycle drops from 187 samples to 13, the ContentCoordinator branch of `MainCoordinator.fanOut` from 191 to 43, and `sharedPipelineState` no longer appears anywhere. The minimap still renders. Also extracts the seven-argument display-options call into `applyDisplayOptions(from:)` so the prewarm and the view controller share one mapping.
Every navigation stack in the app ran on /System/Library/PrivateFrameworks/ UXKit.framework — an Apple-internal framework with no compatibility promise, reached through private API (isNavigationBarHidden, transitionCoordinator, interactivePopGestureRecognizer). AppKitPlus ports that same stack file by file from OpenUXKit, so the interface is name-for-name identical: the Sidebar delegate keeps its macOS 26 backdrop patch verbatim and only the type names move. The USING_SYSTEM_UXKIT flag goes too — only one of its two paths was ever built or tested. Because the stack now takes plain NSViewControllers, the three UX-prefixed bases collapse into BaseViewController / BaseEffectViewController / BaseNavigationController on stock NSViewController and NSNavigationController (the zero-call-site AppKitViewController shell is deleted), and .uxPopover goes away with them: it existed only to bridge UXViewController's private preferredContentSize ivar to NSPopover, so CocoaCoordinator's own .popover now serves. UXKitCoordinator's push/pop/set transitions are replaced by Transition+Navigation.swift in RuntimeViewerArchitectures. Two collisions the binary framework brings with it, both recorded in the proposal. AppKitPlus is deliberately not @_exported: NSKeyConstants.h imports <Carbon/Carbon.h> and the module map re-exports it, which would put Carbon's Control into every file importing RuntimeViewerUI. And its NSView category declares backgroundColor, which turned ContentLineNumberRulerView's own property into an illegal override — renamed to gutterBackgroundColor, which also says better what it fills. Verified: swift build, RuntimeViewerCatalystHelper, RuntimeViewer macOS (Debug), and RunScript.sh Debug-arm64e all build; AppKitPlus.framework embeds with an arm64e slice and the bundle no longer mentions UXKit anywhere.
BaseViewController hand-rolled `loadView()` to install a plain NSView, because NSViewController's own implementation looks up a nib we do not ship. AppKitPlus 0.1.4 provides NSLayerBackedViewController, which both supplies that view and makes it layer-backed, so the override goes away.
`canImport(AppKit) && !targetEnvironment(macCatalyst)` spelled out the same condition as `os(macOS)` while reading as if Catalyst were a live target for these files; it is not, and the longer form kept inviting the question. Then.swift's `!os(Linux)` fence around CoreGraphics goes for the same reason — the package has no Linux platform. The SwiftUI NSVisualEffectView wrapper goes with them: nothing in the project imports it, and RuntimeViewerUI is an AppKit module.
…urce-editor-integration # Conflicts: # Documentations/Evolutions/README.md
Resolution artifacts, not a deliberate bump: merging the UXKit removal pulled AppKitPlus to 0.1.4 and dropped swiftui-introspect, and resolving against it moved MachInjector to 0.5.0 and swift-helper-service to 0.3.1 while adding MachOKitExtensions and swift-objc-dump. Checking them in keeps a fresh checkout resolving to what the branch was built and tested against.
The migration left the content pane still naming UXKitViewController and casting contentView to UXView, which only compiled because the old base class was still around. Name the AppKitPlus base class directly, and drop the cast now that contentView is layer-backed on its own. SourceEditorLoader's bundle name is read from nonisolated code on both sides -- resolve() runs off the main thread and Unavailability is built by nonisolated code -- so it stops inheriting the class's isolation.
…ation Objective-C's grammar gives both names in `@interface NSView : NSResponder` the same rule -- xcode.lang.objc.classname, typed xcode.syntax.name.type -- and SourceModelSyntaxTokenProvider settles any such node inside a declaration as xcode.syntax.declaration.type before it ever consults the installed nodeTypeAdjuster. The superclass therefore came out in the declaration colour, with no way for the semantic runs to correct it. Replace -[SMSourceModel isDeclarationOrDefinitionAtLocation:] so that positions the generator labelled as references answer false, which lets those nodes reach the adjuster; declarations keep the original answer. The selector has exactly one caller across the six SourceEditor frameworks, so nothing else observes the change.
The Xcode-backed pane only wired up cmd-click; right-clicking a token got the framework's own Cut/Copy/Paste and nothing else, so the NSTextView pane's jump actions had no counterpart. Go through SourceEditorView.contextualMenuItemProvider. It is handed the menu and nothing else -- no event, no position -- so the click is located from NSApp.currentEvent, which is still the right-click for the whole path from rightMouseDown to popUpContextMenu and is the same event the framework measures it by. Recording the position from an event consumer instead would only hold while ours runs ahead of the framework's own contextual-menu consumer, an ordering nothing enforces. The items are built app-side, since their targets and actions live there; the bridge just resolves the position to a token range, as it does for cmd-click. Cut is dropped from the menu: validateUserInterfaceItem gates Paste on isEditingEnabled but gates Cut only on a non-empty selection, and right-clicking selects the token, so it stayed live in a read-only view. Pressing it ran copy: and then sent itself delete:, which SourceEditorView does not implement -- it belongs to SourceEditorViewMissingKeyBindings, declared by the framework and left to its host -- so the message was forwarded up a responder chain that ignores it. Harmless, but a Cut that silently means Copy is worse than no Cut. The stub interface gains the provider protocol and property (three symbols); the trimmed .tbd is regenerated, not the full one.
…e module Every wrong answer this module produced came from reasoning about what the framework must do instead of reading what it does, so make the reference material a precondition rather than a preference: a RuntimeViewer dump with both ObjCHeaders and SwiftInterfaces, the framework binaries, and the language/theme specs. Missing any of them means stopping to ask, not inferring. Also records what the last two changes established -- the node-type short circuit that skips the adjuster, and how the contextual menu hooks in -- in the proposal's decision log, with the evidence rather than just the conclusion.
0.1.4's NSLayerBackedViewController ported UXViewController's -updateViewConstraints verbatim -- forward to the root view, never call super -- and NSViewController installs the constraints carrying preferredContentSize from inside that method. Every controller in this app derives from it, so popovers and sheets sized themselves to their content's fitting size and ignored the size they asked for: the background indexing popover declares 380x320 and came out 166x218. 0.1.5 drops the override. AppKit's own implementation already forwards to the view, so nothing the port did is lost.
TabViewController carried its own `loadView` only because NSViewController's would look for a nib named after the class, which this target does not ship — the base class now supplies one. Its macOS 26 content view moves to NSLayerBackedView for the same reason it exists at all: the pane needs a layer without hand-rolling `wantsLayer`.
⌘⇧-click already opened a tab, following Safari, but ⌥⌘ is the habit elsewhere and nothing was using it. Both panes gain it so switching the editor engine does not change what a modifier does. The SourceEditor pane needs no change on the bridge side: its `mouseDown(with:)` offers the event to the registered consumers before anything else and does not inspect modifiers on the way, so ⌥⌘ arrives by the same path ⌘ already does.
Contextual menus here act on a read-only interface listing and the only text fields are filters and settings, so the submenu's Contact… / Passwords… / Credit Card… offers to type a password into a class dump. It cannot be removed once the menu exists — AppKit inserts it as the menu opens, after the item provider has had the menu — so the only seam is the user default guarding it. That default has to be registered from `AppDelegate.init`, not a lifecycle callback: it is first read while the main nib connects `NSApplication.mainMenu`, and the value is cached for the process on that read. Measured three ways, recorded in the proposal's decision log along with the wider switch that takes the whole Services group with it.
"Export Multiple Images…" carries `square.and.arrow.up.on.square` as its secondary image, but the resource table at the end of the nib never listed it.
Groundwork for reacting when the window loses key: the cell needs to know that the row's emphasis has gone grey. Both branches are empty for now — the colours that proved it fires are commented out — so this changes nothing at runtime.
Rebuild SidebarRootTableCellView on AppKitPlus primitives instead of ImageTextTableCellView, rename the row view to SidebarTableRowView and reuse it for the runtime object outline, and dim its background when the row is not emphasized. Still carries the key/main window probe logging.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two proposals' worth of work, plus the docs and small fixes that came with them.
43 commits, 103 files. Targets
nextrather thanmain: proposal 0013 is stillIn Progress, the editor is opt-in and incomplete, and the branch resolves
AppKitPlus against a local package build on this machine.
Accepted, opt-in): puts Xcode'sown editor behind the content pane, coloured from RuntimeViewer's semantic
runs rather than the framework's lexical parse.
In Progress): merged in fromrefactor/replace-uxkit-with-appkitplus. Takes every navigation stack offApple's private
UXKit.framework.Draft): proposal only, no code.direction 0009 and 0010 both hang off, written once the goal moved from
"integrate SourceEditor" to "eventually reimplement it".
0009 — the Xcode-backed content view
The app links none of it. A link-time dependency on frameworks living inside
Xcode would abort in dyld before
mainon a machine without Xcode, so everysymbol reference sits in a loadable bundle (
RuntimeViewerSourceEditorBridge),mirroring the existing CatalystHelperPlugin arrangement.
SourceEditorLoaderdlopens the four frameworks by absolute path, sweeping to a fixed point rather
than following a topological sort — they depend on each other, so one whose
dependencies are not up yet fails a pass and succeeds a later one, which keeps a
future Xcode reshuffling the graph from breaking loading. App's own
Frameworksdirectory is searched before Xcode's, so whether to ship them stays a packaging
decision rather than a code path.
ContentSourceEditorViewControllerbinds the sameContentTextViewModelasContentTextViewController, so choosing between them is the whole of the switchand every failure — no Xcode, an arm64e build, a missing bundle — lands on the
existing
NSTextViewimplementation.Stubs, not headers. The frameworks ship no module interface and next to no
Objective-C surface, so calling them needs a hand-written
.swiftinterface.Nothing Apple-authored is checked in: each stub is a trimmed
.tbdsymbol list(976K → 4K) plus the interface subset. Three reconstruction rules are mechanical
and each cost real time before being written down — a member behind a dispatch
thunk is declared plainly while a direct-symbol-only one must be
final;protocol requirement order must match the witness table (PWT offsets run 0x8,
0x10, 0x18 in declaration order, so a gap means a missing requirement); and a
class's real root matters, because declaring
NSObject-rootedSourceEditorGutteras its own root constructs fine, answers every call fine,and crashes on deallocation.
Stubs/README.mdcarries the procedure, andAuditClasses.sh/AuditMembers.shanswer both questions from the binary whenno dump is at hand.
Colouring from the parse, not over it. The framework tokenizes lexically, so
NSStringis just an identifier to it — while RuntimeViewer knows exactly whatevery identifier is, having rendered the interface from runtime metadata. The
first version pushed colours in through
TextAttributeOverrideProvider; itproduced correct colours over an incorrect parse, so
tokenRangeAtPosition(what⌘-click reads) did not benefit. It is kept in the proposal marked superseded, as
a thing that looks finished and is not. The shipping version rewrites node types
in the language service's
nodeTypeAdjuster, which is how Xcode itself upgradesa lexical parse with index data, so the editor's own machinery becomes semantic.
Node type names turn out to be the theme's syntax keys, and ids shift between
versions, so they are resolved by name at runtime.
One case needed more: Objective-C's grammar gives both names in
@interface NSView : NSResponderthe same rule, andSourceModelSyntaxTokenProvidersettles any such node inside a declaration asxcode.syntax.declaration.typebefore consulting the adjuster. Replacing-[SMSourceModel isDeclarationOrDefinitionAtLocation:]so that positions thegenerator labelled as references answer false lets those nodes reach the
adjuster. That selector has exactly one caller across the six frameworks.
Theme.
SourceEditorThemeConversionoverwrites a copy of the framework's owntheme rather than building one from nothing — the format has ~50 keys, most with
no counterpart in
ThemeProfile. All 28 syntax keys are written: leaving someunwritten left SDK classes on Xcode's purple while image classes followed the
configured theme. Colour components go in as calibrated RGB, not sRGB — the
framework parses
"r g b a"with the equivalent ofNSColor(calibratedRed:), sothe same triple renders 0.404/0.886/0.518 instead of 0.420/0.886/0.459, a visible
and silent difference.
Settings.Theme.Presetgains acurrentLineHighlightslot (with a default, so presets stored before it existed still decode).
Settings › Editor. Master switch plus code folding, sticky headers, minimap,
line numbers, scope guides, invisibles and mark separators. The pane disables the
toggle and says so when no Xcode is found;
XcodeSourceEditorLocatorlives inRuntimeViewerSettings so the pane and the loader agree on what "installed" means.
Things that only showed up once it was on screen, each fixed with the
measurement in the commit:
enableLineNumbers()onlywrites a Bool, and digit sizes come from a font that starts nil, so all 144
line-number layers measured 0x0 and the margin collapsed to 6pt (→ 36pt, layers
8x15).
default, notadditional— the latter is the findpanel's own channel, and writing it threw away the toolbar offset when ⌘F
opened. This corrects an earlier reading of the disassembly.
harness that verified the first attempt had the editor as a direct subview of
the window's content view; the app does not. Harness measured 66, app measured 0.
gated
additionalLeftPaddingon a gutter annotation being present.Startup cost. Three separate measurements, two of which corrected the one
before it. The four
dlopens cost ~60 ms and land on whichever click firstevaluates the controller's
lazy var bridge, so they moved off the main thread.Then a trace showed the prewarm had never run at all — it read
settings.editor.usesSourceEditorsynchronously right afterSettingsLifecycleController.loadOnLaunch()'sTask { await settings.load() },so it read the default (off) and returned; it now observes the setting instead.
Then a cold-launch
sampleput 137 ms of the first object's 185 ms inMinimapMetalLinesLayer'sstatic let sharedPipelineState— a Metal shadercompile, 130 ms of it with the main thread blocked.
prewarmBridge()buildsone bridge with the real display options applied (load-bearing: with the minimap
off, nothing here runs) and hands it to the first document. Verified by a second
sample: the
ContentSourceEditorViewControllerlifecycle drops from 187 samplesto 13, and
sharedPipelineStateno longer appears.Memory, since the spike claimed 2.8× TextKit 2: that figure was an artefact
of measuring in an offscreen window where nothing is under pressure to reclaim.
Attributed on a running app, SourceEditor and SourceModel together are 2.0 MB
across 19978 objects — 0.3% of the heap.
Offscreen rendering is not a usable judge here. It called the colouring broken
three times while it was working, because a cached bitmap goes through a different
render path. It is reliable for structural questions — a class is in the view
tree or it is not — which is how the eight enable/disable cycles were checked, and
that turned up a framework-side leak (re-installing the minimap leaves eight
highlight layers behind each time, strictly linear). Recorded, not worked around.
0013 — UXKit → AppKitPlus
Every navigation stack ran on
/System/Library/PrivateFrameworks/UXKit.frameworkthrough private API (
isNavigationBarHidden,transitionCoordinator,interactivePopGestureRecognizer). AppKitPlus ports the same stack file by filefrom OpenUXKit, so the interface is name-for-name identical and the Sidebar
delegate keeps its macOS 26 backdrop patch verbatim.
USING_SYSTEM_UXKITgoestoo — only one of its two paths was ever built.
Because the stack now takes plain
NSViewControllers, the threeUX-prefixedbases collapse into
BaseViewController/BaseEffectViewController/BaseNavigationController,.uxPopovergives way to CocoaCoordinator's own.popover, andUXKitCoordinator's transitions are replaced byTransition+Navigation.swiftin RuntimeViewerArchitectures.Two collisions the binary framework brings with it: AppKitPlus is deliberately
not
@_exported(itsNSKeyConstants.himports<Carbon/Carbon.h>and themodule map re-exports it, which would put Carbon's
Controlinto every fileimporting RuntimeViewerUI), and its
NSViewcategory declaresbackgroundColor,which made
ContentLineNumberRulerView's own property an illegal override —renamed
gutterBackgroundColor.Three follow-up pins: 0.1.4 for
NSLayerBackedViewController(drops a hand-rolledloadView), 0.1.5 because 0.1.4's portedupdateViewConstraintsnever calledsuper, so every popover and sheet ignored its
preferredContentSize(thebackground-indexing popover declares 380x320 and came out 166x218), and 0.1.6 to
take
TabViewController'sloadViewthe same way.Also in here
SystemAutoFillMenuSuppression— AppKit's AutoFill submenu offeredContact… / Passwords… / Credit Card… on contextual menus over a read-only class
dump. It cannot be removed once the menu exists (AppKit inserts it as the menu
opens, after the item provider has had it), so the only seam is the user default
— which must be registered from
AppDelegate.init, since it is first read whilethe main nib connects
NSApplication.mainMenuand cached for the process there.contextualMenuItemProvider. Cut is dropped from that menu: it was gated onlyon a non-empty selection, right-clicking selects the token, and pressing it ran
copy:then sent itselfdelete:— whichSourceEditorViewdoes not implement.EXCLUDED_ARCHS=x86_64for Debug-arm64e builds. AppKitPlus's XCFramework coversarm64 and arm64e only, so the x86_64 variant of any package importing it dies in
AppKitPlus-Swift.h. It has to be a command-line setting: the Debug-arm64excconfig is the app target's base configuration alone, and the targets that fail
are the SwiftPM ones.
com.apple.security.cs.disable-library-validationon the app. It may well beunnecessary — library validation permits Apple-signed code and these frameworks
are Apple-signed — but that cannot be confirmed on a machine with library
validation switched off, and the failure mode if the reasoning is wrong is the
editor silently never loading on user machines.
AGENTS.mdgains a SourceEditor Module section whose first rule is that thedump and the binaries are a precondition, not a preference. Every wrong answer
this module produced came from reasoning about what the framework must do
instead of reading what it does.
Before merging
0013-inject-ios-simulator-process.mdis also targetingnext, and both editDocumentations/Evolutions/README.md, so whichever lands second needs arenumber and a conflict resolution. Nothing here forces the order.
pass
isEnabled: true, which bypasses theUSING_LOCAL_DEPENDENCIESgate everyother local dependency in
Package.swifthonours — so on a machine where eitherpath exists, every build takes the local copy, release included. Elsewhere it
falls back to
exact: "0.1.6"and resolves normally.backgroundStyleprobein
RuntimeObjectCellView, anNSTableCellView.rowViewextension with no callsites, and
SidebarTableRowViewpainting unemphasized rows in.disabledControlTextColor(a text colour used as a background).Still open in the proposals
0009:
lineWrappingStyle/ overscroll (value for a read-only interface isdoubtful); distinguishing SDK from project symbols, which needs a new
ThemeProfilestyle and so is a theme-model decision of its own; a notarizationpass to confirm the entitlement is harmless; and the question of whether a
separate implementation note is warranted, given the proposal already carries most
of it and
Stubs/README.mdcovers interface reconstruction.0013: walking the three panes on a real run to confirm the transitions and the
macOS 26 backdrop patch still hold, and re-measuring the motivation with
sample.