Skip to content

SourceEditor-backed content view (0009), and UXKit → AppKitPlus (0013) - #107

Merged
Mx-Iris merged 43 commits into
nextfrom
feature/source-editor-integration
Aug 24, 2026
Merged

SourceEditor-backed content view (0009), and UXKit → AppKitPlus (0013)#107
Mx-Iris merged 43 commits into
nextfrom
feature/source-editor-integration

Conversation

@Mx-Iris

@Mx-Iris Mx-Iris commented Aug 24, 2026

Copy link
Copy Markdown
Member

Two proposals' worth of work, plus the docs and small fixes that came with them.
43 commits, 103 files. Targets next rather than main: proposal 0013 is still
In Progress, the editor is opt-in and incomplete, and the branch resolves
AppKitPlus against a local package build on this machine.

  • 0009 — SourceEditor-backed content view (Accepted, opt-in): puts Xcode's
    own editor behind the content pane, coloured from RuntimeViewer's semantic
    runs rather than the framework's lexical parse.
  • 0013 — UXKit → AppKitPlus (In Progress): merged in from
    refactor/replace-uxkit-with-appkitplus. Takes every navigation stack off
    Apple's private UXKit.framework.
  • 0010 — interface snapshot export (Draft): proposal only, no code.
  • Vision: 自建代码视图引擎 — the
    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 main on a machine without Xcode, so every
symbol reference sits in a loadable bundle (RuntimeViewerSourceEditorBridge),
mirroring the existing CatalystHelperPlugin arrangement. SourceEditorLoader
dlopens 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 Frameworks
directory is searched before Xcode's, so whether to ship them stays a packaging
decision rather than a code path.

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 NSTextView implementation.

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 .tbd symbol 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-rooted
SourceEditorGutter as its own root constructs fine, answers every call fine,
and crashes on deallocation. Stubs/README.md carries the procedure, and
AuditClasses.sh / AuditMembers.sh answer both questions from the binary when
no dump is at hand.

Colouring from the parse, not over it. The framework tokenizes lexically, so
NSString is just an identifier to it — while RuntimeViewer knows exactly what
every identifier is, having rendered the interface from runtime metadata. The
first version pushed colours in through TextAttributeOverrideProvider; it
produced 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 upgrades
a 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 : NSResponder the same rule, and
SourceModelSyntaxTokenProvider settles any such node inside a declaration as
xcode.syntax.declaration.type before consulting the adjuster. Replacing
-[SMSourceModel isDeclarationOrDefinitionAtLocation:] so that positions the
generator labelled as references answer false lets those nodes reach the
adjuster. That selector has exactly one caller across the six frameworks.

Theme. SourceEditorThemeConversion overwrites a copy of the framework's own
theme rather than building one from nothing — the format has ~50 keys, most with
no counterpart in ThemeProfile. All 28 syntax keys are written: leaving some
unwritten 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 of NSColor(calibratedRed:), so
the same triple renders 0.404/0.886/0.518 instead of 0.420/0.886/0.459, a visible
and silent difference. Settings.Theme.Preset gains a currentLineHighlight
slot (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; XcodeSourceEditorLocator lives in
RuntimeViewerSettings 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:

  • The gutter shows no line numbers without a font: enableLineNumbers() only
    writes 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).
  • Content insets go in default, not additional — the latter is the find
    panel's own channel, and writing it threw away the toolbar offset when ⌘F
    opened. This corrects an earlier reading of the disassembly.
  • Safe area must be read off the controller's view, not the editor view. The
    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.
  • The folding ribbon sat flush against the first character, because macOS 26
    gated additionalLeftPadding on 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 first
evaluates 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.usesSourceEditor synchronously right after
SettingsLifecycleController.loadOnLaunch()'s Task { await settings.load() },
so it read the default (off) and returned; it now observes the setting instead.
Then a cold-launch sample put 137 ms of the first object's 185 ms in
MinimapMetalLinesLayer's static let sharedPipelineState — a Metal shader
compile, 130 ms of it with the main thread blocked. prewarmBridge() builds
one 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 ContentSourceEditorViewController lifecycle drops from 187 samples
to 13, and sharedPipelineState no 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.framework
through private API (isNavigationBarHidden, transitionCoordinator,
interactivePopGestureRecognizer). AppKitPlus ports the same stack file by file
from OpenUXKit, so the interface is name-for-name identical and the Sidebar
delegate keeps its macOS 26 backdrop patch verbatim. USING_SYSTEM_UXKIT goes
too — only one of its two paths was ever built.

Because the stack now takes plain NSViewControllers, the three UX-prefixed
bases collapse into BaseViewController / BaseEffectViewController /
BaseNavigationController, .uxPopover gives way to CocoaCoordinator's own
.popover, and UXKitCoordinator's transitions are replaced by
Transition+Navigation.swift in RuntimeViewerArchitectures.

Two collisions the binary framework brings with it: AppKitPlus is deliberately
not @_exported (its 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 made ContentLineNumberRulerView's own property an illegal override —
renamed gutterBackgroundColor.

Three follow-up pins: 0.1.4 for NSLayerBackedViewController (drops a hand-rolled
loadView), 0.1.5 because 0.1.4's ported updateViewConstraints never called
super, so every popover and sheet ignored its preferredContentSize (the
background-indexing popover declares 380x320 and came out 166x218), and 0.1.6 to
take TabViewController's loadView the same way.

Also in here

  • SystemAutoFillMenuSuppression — AppKit's AutoFill submenu offered
    Contact… / 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 while
    the main nib connects NSApplication.mainMenu and cached for the process there.
  • ⌥⌘-click opens a new tab, alongside the existing ⌘⇧-click, in both panes.
  • Jump to Definition restored in the SourceEditor pane's contextual menu, via
    contextualMenuItemProvider. Cut is dropped from that menu: it was gated only
    on a non-empty selection, right-clicking selects the token, and pressing it ran
    copy: then sent itself delete: — which SourceEditorView does not implement.
  • EXCLUDED_ARCHS=x86_64 for Debug-arm64e builds. AppKitPlus's XCFramework covers
    arm64 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-arm64e
    xcconfig is the app target's base configuration alone, and the targets that fail
    are the SwiftPM ones.
  • com.apple.security.cs.disable-library-validation on the app. It may well be
    unnecessary — 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.md gains a SourceEditor Module section whose first rule is that the
    dump 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

  1. Proposal number 0013 collides with Inject into iOS Simulator processes (Evolution 0013) #106. That branch's
    0013-inject-ios-simulator-process.md is also targeting next, and both edit
    Documentations/Evolutions/README.md, so whichever lands second needs a
    renumber and a conflict resolution. Nothing here forces the order.
  2. AppKitPlus resolves against a local package build. Both local search paths
    pass isEnabled: true, which bypasses the USING_LOCAL_DEPENDENCIES gate every
    other local dependency in Package.swift honours — so on a machine where either
    path exists, every build takes the local copy, release included. Elsewhere it
    falls back to exact: "0.1.6" and resolves normally.
  3. Leftovers from the last few commits: a commented-out backgroundStyle probe
    in RuntimeObjectCellView, an NSTableCellView.rowView extension with no call
    sites, and SidebarTableRowView painting 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 is
doubtful); distinguishing SDK from project symbols, which needs a new
ThemeProfile style and so is a theme-model decision of its own; a notarization
pass 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.md covers 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.

Mx-Iris added 30 commits August 16, 2026 21:39
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.
Mx-Iris added 13 commits August 19, 2026 18:28
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.
Copilot AI lite review requested due to automatic review settings August 24, 2026 03:07

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@Mx-Iris
Mx-Iris merged commit 4bce7cb into next Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants