Diese Notiz bewahrt technische Befunde, deren Ursachen und die verifizierten
Gegenmaßnahmen. Die historische Abschnittsnummer F bleibt erhalten, weil
Code-Kommentare gezielt auf einzelne Unterpunkte verweisen.
Beobachtung: Die natürliche Annahme war, Info.plist in Sources/Fastra/Resources/ zu legen und über .process("Resources") einzubinden. SPM bricht dann mit einem harten Fehler ab:
error: resource 'Resources/Info.plist' in target 'Fastra' is forbidden; Info.plist is not supported as a top-level resource file in the resources bundle
Lösung, die funktioniert (ohne Warnung): Info.plist an den Package-Root legen (neben Package.swift) und per Linker-Section in die Binary einbetten:
.executableTarget(
name: "Fastra",
resources: [.process("Resources")],
linkerSettings: [
.unsafeFlags([
"-Xlinker", "-sectcreate",
"-Xlinker", "__TEXT",
"-Xlinker", "__info_plist",
"-Xlinker", "Info.plist"
])
]
)Das funktioniert sowohl mit swift build als auch beim Öffnen in Xcode. Wenn Info.plist irgendwo unter Sources/ liegt — selbst mit exclude: ["Info.plist"] — gibt es eine "unhandled file"-Warnung.
Beobachtung: Der praktische SwiftUI-Macro #Preview { ContentView() } braucht PreviewsMacros von Xcode. Bei swift build aus dem Terminal kommt:
error: external macro implementation type 'PreviewsMacros.SwiftUIView' could not be found
Lösung: #Preview-Blöcke in den Sources weglassen, wenn der Workflow CLI-Builds einschließt. Stattdessen Previews erst nach dem Öffnen in Xcode hinzufügen, oder via #if DEBUG && canImport(SwiftUI) && canImport(PreviewsMacros) einklammern (umständlich).
Anders als befürchtet wird Assets.xcassets (inkl. AppIcon.appiconset) vom SPM-Build sauber kompiliert. Ergebnis ist Assets.car neben der Binary. Das App-Icon erscheint allerdings nur, wenn Info.plist CFBundleIconName=AppIcon und CFBundleIconFile=AppIcon enthält UND die App in einer richtigen .app-Bundle-Struktur läuft (siehe A.4).
Beobachtung: swift run startet die Binary im Terminal-Kontext. Das Fenster erscheint, aber:
- Dock-Icon fehlt
- Menüleiste integriert nicht vollständig
- App quittiert nicht über ⌘Q (Terminal fängt es ab)
- LSUIElement-Verhalten ist seltsam
Lösung für volle Mac-App-Erfahrung: open -a Xcode Package.swift, dann ▶. Xcode produziert ein echtes .app-Bundle. Alternativ manuelles Bundling per Skript.
Beobachtung: SwiftPMs generierter resource_bundle_accessor.swift sucht ein
Ressourcenbundle zuerst direkt unter Bundle.main.bundleURL. Als zweiten Pfad
kompiliert SwiftPM den absoluten lokalen .build/<Konfiguration>-Pfad ein.
Fastra legte die Bundles fälschlich unter Contents/Resources ab. Auf dem
Build-Mac startete die App trotzdem über den absoluten Fallback; auf einem
anderen Mac crashte sie sofort in Bundle.module.
Verbindliche Lösung: In Fastra und betroffenen Fremdmodulen eigene Locator
verwenden, die in einer gepackten App zuerst Bundle.main.resourceURL prüfen,
und SwiftPM-.bundle-Verzeichnisse unter Contents/Resources kopieren.
Bundles direkt in der .app-Wurzel sind keine Alternative: Codesign lehnt sie
als „unsealed contents" ab. Danach verify-portable-app.sh ausführen: Das
Skript blendet alle lokalen Build-Bundles kurz aus und verlangt einen echten
fensterlosen localization-Start sowie eine Ordnersuche mit dem gebündelten
ripgrep aus der gepackten App. Der zweite Lauf ist nötig, weil ein einzelner
später Pfad (konkret 2026-08-05: RipgrepFileEnumerator) beim Start noch
unberührt bleiben und erst beim ersten Suchlauf in Bundle.module crashen
kann. build.sh und install.sh rufen dieses Gate automatisch auf. Codesign,
Notarisierung, Stapler und Gatekeeper prüfen Integrität und Vertrauen, aber
nicht, ob die App ihre Laufzeitressourcen findet.
Mehrere voneinander unabhängige Probleme der gepinnten Abhängigkeiten treffen
aufeinander, sobald Fastra über die Kommandozeile gebaut wird. build.sh
kapselt und verifiziert die weiterhin benötigten Gegenmaßnahmen.
In 0.15.2 (gepinnt in Package.resolved) heißt der öffentliche Typ
SourceEditor und nimmt:
SourceEditor(
$text,
language: CodeLanguage.detectLanguageFrom(url: someURL), // oder .default
configuration: SourceEditorConfiguration(
appearance: .init(
theme: editorTheme,
font: NSFont.monospacedSystemFont(ofSize: 13, weight: .regular),
wrapLines: false,
tabWidth: 4
)
),
state: $editorState // SourceEditorState mit cursorPositions etc.
)EditorTheme hat upstream 16 obligatorische Felder (text, insertionPoint,
invisibles, background, lineHighlight, selection, plus 10 Token-Attribute)
und nimmt jeweils EditorTheme.Attribute(color:bold:italic:)-Werte mit
NSColor, nicht Color. Fastras Patch 4m ergänzt nur für Fastra ein
optionales Feld methods, das standardmäßig commands übernimmt. Dadurch
bleiben alle fremden Themes quellkompatibel; die 4D-Themes in
EditorView.swift können trotzdem die eigene Methodenfarbe setzen.
Sowohl CodeEditSourceEditor als auch CodeEditTextView deklarieren ein BuildToolPlugin für SwiftLint (lukepistrol/SwiftLintPlugin). Das prebuilt SwiftLint-Binary unter .build/artifacts/swiftlintplugin/SwiftLintBinary/SwiftLintBinary.artifactbundle/macos/swiftlint wirft beim Start:
SourceKittenFramework/library_wrapper.swift:58:
Fatal error: Loading sourcekitdInProc.framework/Versions/A/sourcekitdInProc failed
Das sourcekitdInProc.framework existiert sowohl in CommandLineTools (/Library/Developer/CommandLineTools/usr/lib/) als auch in Xcode (/Applications/Xcode.app/.../XcodeDefault.xctoolchain/usr/lib/). DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer allein und auch --disable-sandbox reichen nicht — das prebuilt Binary findet das Framework über @rpath nicht.
Workaround: Lokal die plugins: [.plugin(name: "SwiftLint", …)]-Blöcke und die SwiftLintPlugin-Dependency aus den Package.swift-Dateien im .build/checkouts/-Ordner herauskommentieren. Das Build-Skript build.sh macht das automatisch via perl -i -0pe-Patches und re-applied sie nach jedem swift package update.
CodeEditSymbols/Package.swift deklariert das Target ohne resources:-Block, der Source-Code referenziert aber Bundle.module.image(forResource:). Build-Fehler: type 'Bundle' has no member 'module'. Workaround: resources: [.process("Symbols.xcassets")] ergänzen (auch im build.sh automatisiert). Sollte als Issue beim Upstream gemeldet werden.
In CodeEditSourceEditor/Sources/CodeEditSourceEditor/Find/PanelView/ stehen #Preview (Xcode-SwiftUI-Preview-Macro)-Blöcke in produktiven Sources, nicht in Test-Targets. Das Macro-Plugin PreviewsMacros ist nur in Xcode-Toolchains enthalten, nicht in CommandLineTools. Build-Fehler: external macro implementation type 'PreviewsMacros.SwiftUIView' could not be found.
Workaround: Build über Xcode-Toolchain treiben:
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcrun --toolchain XcodeDefault swift buildxcrun --toolchain XcodeDefault wechselt swift selbst auf die Xcode-Variante; DEVELOPER_DIR zusätzlich, damit Sub-Tools (sourcekitd, swift-frontend-Plugins) die Xcode-Pfade finden. Beides ist nötig — eines allein reicht nicht.
build.sh kapselt die hier beschriebenen Checkout-Patches vollständig. Aufruf:
./build.sh # debug
./build.sh release # releaseDie Patches sind idempotent und ändern den .build/checkouts/-Inhalt, nicht den
Produktcode. Nach swift package update muss das Skript erneut laufen.
Beim Linkschritt erscheinen viele Warnungen der Form
warning: (arm64) /Users/Khan/Developer/CodeEditLanguages/DerivedData/.../parser.o
unable to open object file: No such file or directory
Das sind Debug-Info-Pfade aus den prebuilt TreeSitter-Static-Libraries, die auf den Build-Host des Library-Autors zeigen. Sie betreffen nur DWARF-Symbolisierung, nicht die Funktion. Wenn das langfristig stört: TreeSitter selbst bauen statt prebuilt.
Beim ersten Start kracht der Editor mit NSInvalidArgumentException im AppKit-Layout:
*** -getHue:saturation:brightness:alpha: not valid for the NSColor
Generic Gray Profile Gamma 2,2 colorspace; need to first convert colorspace.
Ursache: MinimapView.setTheme() ruft auf jeder Theme-Farbe brightnessComponent auf, was nur im RGB-Colorspace funktioniert. NSColor.white und NSColor(white:alpha:) liegen im Generic-Gray-Profile — die exception fliegt sofort beim Initial-Layout (lange bevor man die Minimap überhaupt sieht).
Lösung: Alle Farben im EditorTheme konsequent über NSColor(srgbRed:green:blue:alpha:) erzeugen. Helper in EditorView.swift:
private static func rgb(_ r: Int, _ g: Int, _ b: Int, _ a: CGFloat = 1) -> NSColor {
NSColor(srgbRed: CGFloat(r)/255, green: CGFloat(g)/255, blue: CGFloat(b)/255, alpha: a)
}Diagnose via lldb -o "b objc_exception_throw" -o "run" — ohne den Breakpoint sieht man im Stderr nichts, weil SwiftUI die Exception schluckt und der Stack-Trace ein generischer QuartzCore-CA::Transaction-Crash ist.
CodeEditSourceEditor installiert beim Laden einen eigenen keyDown-Monitor (TextViewController.handleCommand), der bei fokussiertem Editor CMD+F abfängt und sein internes Find-Panel via showFindPanel() zeigt. Wir wollen stattdessen unsere eigene Suchmaske. Das Rennen der konkurrierenden NSEvent-Local-Monitore (LIFO) ist nicht zuverlässig gewinnbar — das Panel blitzte trotz Write-back-Reconcile (EditorView.onChange(findPanelVisible)) noch kurz auf, weil showFindPanel() 0,15 s einanimiert.
Workaround (vierter build.sh-Patch): Im resolved Checkout …/Controller/TextViewController+Lifecycle.swift den CMD+F-Zweig von
case (commandKey, "f"):
_ = self.textView.resignFirstResponder()
self.findViewController?.showFindPanel()
return nilauf return event umbiegen (CMD+F durchreichen statt Panel zeigen). Damit fängt CMD+F ausschließlich unser App-Monitor ab — deterministisch, unabhängig von der Monitor-Reihenfolge.
Zwei Stolpersteine:
- SPM trackt Quell-Änderungen innerhalb von
.build/checkouts/NICHT (Dependencies gelten als immutable). Der Patch landet sonst nicht im Binary. → build.sh verwirft nach dem Patchen die CESE-Build-Artefakte (.build/*/{debug,release}/CodeEditSourceEditor.build+.swiftmodule), damit SPM neu übersetzt. - Der Patch verifiziert sich selbst (
grepnachshowFindPanel()muss danach leer sein, sonstexit 1) — nach einem Versions-Bump mit geänderter Quelle kehrt der Zombie sonst lautlos zurück.
Verifikation: ./selftest.sh findbar pollt auf transientes Aufblitzen. Volle
Begründung und verbotene Irrwege stehen in
../docs/BUILD-AND-TEST.md unter „QA-Strategie“.
Der Treffer-Sprung (CMD+G / Listen-Klick / Voriger-Nächster) setzt editorState.cursorPositions; CodeEditSourceEditor soll daraus die Editor-Selektion ableiten und in Sicht scrollen. Tat es NIE. Grund ist ein Bug in CESE 0.15.x: SourceEditor.updateControllerWithState prüft
if let cursorPositions = state.cursorPositions, cursorPositions != state.cursorPositions {
controller.setCursorPositions(cursorPositions)
}Die Bedingung vergleicht die frisch gebundene Variable mit sich selbst → immer false. setCursorPositions() läuft dadurch nur einmal in makeNSViewController (Editor-Erzeugung); jede spätere Änderung von außen verpufft. Reine Unit-Tests sahen das nicht — sie durchlaufen das CESE-Zeile/Spalte→Selektion-Mapping nicht.
Workaround (fünfter build.sh-Patch): Im resolved Checkout …/SourceEditor/SourceEditor.swift die Bedingung auf den IST-Stand des Controllers umbiegen und beim Anwenden in Sicht scrollen:
if let cursorPositions = state.cursorPositions, cursorPositions != controller.cursorPositions {
controller.setCursorPositions(cursorPositions, scrollToVisible: true)
}Gleiche zwei Stolpersteine wie F.9 (SPM trackt Checkout-Änderungen nicht → CESE-Artefakte verwerfen; Patch verifiziert sich selbst via grep). Zusätzlich nötig: CodeEditTextView explizit als Dependency deklariert (war transitiv, 0.12.1), damit der Selbsttest die echte Editor-Selektion typsicher zurücklesen kann.
Aufgedeckt und verifiziert durch ./selftest.sh jump: Der Test lädt Text mit
unterschiedlich langen Vorzeilen einschließlich Emoji-Surrogatpaar, postet
einen Sprung exakt wie die GUI und liest TextView.selectedRange() zurück.
Der selektierte Text muss exakt der Treffer sein; damit ist auch der Offset-Fix
(Zeile/Spalte statt absoluter Range) Ende-zu-Ende belegt.
CodeEditSourceEditor setzt in SuggestionViewModel.showCompletions vor der
asynchronen Delegate-Anfrage activeTextView. Fastras 4D-Delegate antwortet
beim ersten Buchstaben absichtlich mit nil, weil die automatische
Vervollständigung erst ab zwei Zeichen erscheinen soll. Upstream kehrte dann
sofort zurück, ohne den bereits gesetzten Zustand zu schließen. Die zweite
Anfrage hielt activeTextView deshalb für aktiv und aktualisierte lediglich
eine nicht sichtbare Liste.
Workaround (Patch 4c1 in build.sh): Im nil-Zweig
self.willClose() aufrufen und erst dann zurückkehren. Das ist enger als ein
neuer Trigger oder ein eigener Popup-Controller: Es räumt nur die unvollständige
Upstream-Anfrage auf und lässt die bestehende CESE-Bedienung unverändert.
Regressionstest: ./selftest.sh completion4d erzeugt eine echte
.4dm-Fixture, lädt sie in den laufenden Editor und fügt AL über die
öffentliche TextView-Eingabe ein. Er beobachtet danach das echte Child-Window
mit seiner NSTableView. Anschließend öffnet er die Liste über ⌃Leertaste und
prüft ↓, gezielten Klick sowie Doppelklick bis zur Einfügung von ALERT.
Der kurze Wait vor ↓ ist kein Produkt-Delay: CESE befüllt die Tabelle über
einen asynchronen Publisher; ohne abgeschlossenen Reload konnte eine korrekte
Auswahl direkt wieder auf die Startzeile zurückspringen.
CodeEditTextView begrenzt Soft Wrap upstream ausschließlich auf die
Viewportbreite. CodeEditSourceEditors vorhandene Reformatting-Linie berechnete
ihre Position zugleich mit font.charWidth / 2 und
textViewInsets.left / 2. Damit lag sie weder an der konfigurierten Textspalte
noch an einer reproduzierbaren Umbruchgrenze. Zusätzlich zeichnete die bereits
nach rechts versetzte Guide-View mit ihrem frame; dieser liegt im
Koordinatensystem des Eltern-Views und verschob Linie und Schattierung beim
Zeichnen ein zweites Mal.
Workaround (Patch 4n in build.sh):
TextLayoutManager.maximumWrapWidthbegrenzt die vorhandene Viewport-Layoutbreite optional; der Viewport bleibt immer die harte Obergrenze.SourceEditorConfiguration.Behavior.wrapAtColumntransportiert das Spaltenziel. Controller und Guide berechnen beideSpalte × (reale Schriftbreite + Kern)ab dem tatsächlichen linken Layout-Inset und reagieren auf Font-/Kernwechsel.ReformattingGuideViewzeichnet in lokalenbounds.- Liefert CoreText bei extrem schmaler Breite keinen Fortschritt, fällt der Typesetter auf genau ein vollständiges zusammengesetztes Zeichen zurück.
- Beim Wechsel von Soft Wrap darf nicht der absolute Y-Scrollwert erhalten bleiben: Durch die neue Zahl der Umbruchfragmente zeigt er auf eine andere logische Zeile. Zeitversetzte Nachkorrekturen sind ebenfalls falsch: CodeEdits Lazy-Layout verschiebt den Anker danach erneut und lässt den Ausschnitt sichtbar zwischen zwei Positionen pendeln. Der Patch merkt die tatsächlich oberste Textzeile, konvergiert in höchstens 24 Layoutschritten innerhalb desselben Runloops und setzt nur die stabile Endposition sichtbar. Ein vollständiges Layout aller vorherigen Zeilen bleibt unnötig.
Regressionen: SoftWrapLayoutTests verwenden den realen
TextViewController, beobachten dessen maximale Layoutbreite, Viewport-Minimum,
Guide-Geometrie samt Gutter und Fontwechsel, rendern die versetzte Guide-View in
ein Bitmap und prüfen Unicode-Fortschritt. ./selftest.sh softwrapmodes prüft
zusätzlich im echten Fenster Fensterbreite, Page Guide und feste Spalte sowie
Resize, Zoom, Auswahl, Text, Dirty-Zustand und Undo-Stack.
./selftest.sh softwrapanchor scrollt in einem Dokument mit 2.400 langen Zeilen
tief nach unten und beobachtet alle 20 ms unabhängig, dass beim Aus- und
Einschalten dieselbe logische Textzeile ohne Zwischenabweichung oben bleibt.
CodeEditTextView erzeugt upstream die Spaltenauswahl aus allen sichtbaren
lineFragments. Unter Soft Wrap wird eine lange logische Zeile deshalb
mehrfach ausgewählt. Eine bloße Filterung der Fragmente reicht nicht: Tabs,
kurze Zeilen und zusammengesetzte Unicode-Zeichen brauchen weiterhin eine
eindeutige Spalten- und UTF-16-Abbildung.
Workaround (Patch 4o in build.sh): Die versionierte Ersatzdatei unter
Patches/CodeEditTextView/ bildet jeden Drag-Punkt zuerst auf logische
NSString-Zeile und visuelle Spalte ab. Sie iteriert Swift-Character, zählt
Tabs bis zum nächsten Tabstopp und setzt pro logischer Zeile genau einen
graphem-sicheren UTF-16-Bereich. Copy/Paste, Paste Column und Zeichen-
Transformationen teilen diesen Zustand. Mehrfachänderungen werden im
CEUndoManager ausdrücklich gruppiert.
Zwei Randfälle brauchen eigene Wächter:
- Ein Nullbereich auf einer kurzen Zeile darf bei Backspace/Delete nicht in Upstreams „Zeichen am Cursor löschen“-Pfad fallen; er bleibt unverändert.
- Eine Zeichen-Transformation darf einen Nullbereich nicht als „keine Auswahl = ganzes Dokument“ interpretieren.
./selftest.sh colsel colselwrap colpaste prüft diese Fälle zusammen mit
echtem Soft Wrap, Vorwärts/Rückwärts, Tabs, CRLF, Unicode, Clipboard-
Mismatch-Regel und exakt einer Undo-Gruppe.
Endet ein Dokument mit einem Zeilenumbruch, liegt rectForOffset(documentEnd)
bereits am linken Rand der nachfolgenden leeren Dateiende-Zeile.
TextSelectionManager.getFillRects verwendete diese X-Position trotzdem als
rechte Kante der vorherigen Textzeile. Bei „Alles auswählen“ entstand dort
deshalb ein Auswahlrechteck mit Breite null: Die Range war korrekt, die letzte
Textzeile wirkte aber unmarkiert. Soft Wrap machte den Effekt durch die
Fragmentgeometrie auffälliger, war jedoch nicht die Ursache.
Workaround (Patch 4p in build.sh): Enthält das letzte ausgewählte
Zeilenfragment eine LF-, CRLF- oder CR-Zeilenendung, reicht seine Markierung
bis zum rechten Textrand. Ohne abschließenden Zeilenumbruch bleibt die bisherige
zeichenexakte rechte Kante erhalten.
Regression: SoftWrapLayoutTests.selectAllIncludesLastVisibleLine setzt
die echte CodeEdit-Auswahl per selectAll, prüft die vollständige
Dokumentrange und verlangt für die letzte Textzeile ein sichtbares
Auswahlrechteck. Der Fall läuft getrennt mit LF, CRLF und CR.
„Zeilen verbinden“ ersetzt den bearbeiteten Bereich bewusst in einer einzigen Undo-fähigen Mutation. CodeEditTextView setzt die Auswahl danach standardmäßig ans Ende des Ersatztexts. Bei einem ganzen Markdown-Dokument ist das eine einzige, tausende Zeichen lange Soft-Wrap-Zeile. Der Cursor am Zeilenende und der noch am Dokumentanfang liegende Viewport können dadurch während der asynchronen Highlight-Aktualisierung verschiedene Layoutstände beobachten: Der Modelltext bleibt vollständig, aber Text und Gutter werden leer gerendert.
Beim Undo entsteht ein zweiter Fehler: CEUndoManager rekonstruiert die Auswahl
aus der Mutationsrange und markiert deshalb das gesamte alte Dokument. Sein
Scrollanker kann dann vor der neu aufgebauten ersten Zeile liegen, sodass ein
leerer Bildschirm oberhalb von Zeile 1 erscheint.
Workaround (Patch 4q in build.sh): Fastras Textoperationspfad bildet die
alte Auswahl bewusst auf den Ersatzbereich ab. Bei einer Ganzdokument-Operation
ohne Auswahl bleibt der Cursor am stabilen Blockanfang. Dasselbe gilt, wenn
Cmd+A das gesamte Dokument als Operationsbereich ausgewählt hat: Die riesige
Auswahl wird nach Verbinden sowie für Undo/Redo zu einem Cursor am Anfang
reduziert, weil sie sonst selbst zum fehlerhaften Layoutanker wird. Eine
opt-in-Erweiterung des CEUndoManager speichert nur für solche Operationen die
stabilen Zustände vor und nach der Mutation. Undo und Redo bauen das Layout
synchron an diesem Anker auf und scrollen ihn sichtbar. Gewöhnliches Tippen,
Einfügen und CodeEdits übrige Undo-Semantik bleiben unverändert.
Regressionen: SoftWrapLayoutTests.joinLinesAndUndoKeepTextVisible nutzt
einen echten TextViewController mit einer 61-zeiligen CSS-Datei, Soft Wrap
aus und echter Vollauswahl. Es verlangt nach Verbinden, Undo und Redo jeweils
korrekten Text, Cursor am Dokumentanfang und sichtbare Layoutfragmente.
./selftest.sh joinundo führt denselben Cmd+A-Menüpfad im gepackten Editor aus
und zählt reale sichtbare LineFragmentViews; ein bloßer Vergleich des
Modelltexts hätte den ursprünglichen Fehler nicht erkannt.
macOS bindet Ordnerfreigaben wie Desktop und Dokumente nicht nur an die
Bundle-ID, sondern auch an die Code-Anforderung der App. Wiederholt nach
/Applications kopierte Ad-hoc-Builds besitzen keine stabile Developer-Team-
Identität. Im TCC-Log erscheint dann trotz unveränderter Bundle-ID
Failed to match existing code requirement; macOS fragt die Ordnerfreigabe
erneut ab. Der Dialog beweist dabei nicht, dass das geöffnete Projekt in diesem
Ordner liegt: Auch ein Systemdialog oder eine frühere URL kann den geschützten
Dienst ansprechen.
Konsequenz: build.sh und install.sh --no-notarize legen Test-Bundles
ausschließlich im Projekt-Root ab. Nur install.sh nach erfolgreicher
Notarisierung, Stapler-Prüfung, Gatekeeper-Abnahme und Codesignaturprüfung darf
/Applications/Fastra.app ersetzen. So bleibt die Code-Identität produktiver
Installationen über Versionswechsel stabil.
CodeEditTextView ruft nach jedem moveDownAndModifySelection korrekt
scrollSelectionToVisible() auf. Die Funktion verwendete jedoch das
Bounding-Rect der Auswahl. Dieses entsteht aus Fill-Rects, die auf den aktuell
sichtbaren Textbereich begrenzt sind. Verlässt die bewegte Auswahlkante den
Viewport, beschreibt das vermeintliche Scrollziel daher weiterhin den schon
sichtbaren Teil; die feste Pivot-Kante bleibt im Bild, die aktive Kante läuft
unten heraus.
Workaround (Patch 4r in build.sh): CodeEdits vorhandener Helfer
offsetNotPivot bestimmt die tatsächlich bewegte Kante. Nur deren kleines
Zeichenrechteck wird sichtbar gescrollt; bei einer Bewegung zurück über den
Pivot wechselt die aktive Seite automatisch. Eine gewöhnliche Cursorbewegung
behält ihr bisheriges Verhalten, weil Pivot und Cursorposition dort
zusammenfallen. Bei einem langen Soft-Wrap-Dokument reicht dieser synchrone
Aufruf allein nicht: Eine weit unten liegende Zeile besitzt zunächst nur eine
geschätzte Höhe. Das Scrollen kann sie erstmals auslegen und dadurch ihre echte
Position unter den gerade erreichten Viewport verschieben. Ein ausschließlich
für verbleibende Auswahlen geplanter zweiter Abgleich im folgenden Main-Runloop
verwendet die stabilisierte Position, ohne normale Cursorbewegungen doppelt zu
bearbeiten. AppKit rundet den Scrollursprung außerdem auf Gerätepixel. Das
Scrollziel erhält deshalb vertikal einen Punkt Reserve; sonst kann die aktive
Zeichenbox trotz korrekter Rechnung um einen Bruchteil eines Punkts außerhalb
des Viewports bleiben.
Regressionen: SoftWrapLayoutTests.extendingSelectionDownScrollsActiveEdgeIntoView
verwendet den echten TextViewController, beginnt in einem bereits nach unten
versetzten kurzen Viewport, führt den NSTextInputClient-Befehl sechsmal aus und
verlangt sowohl einen veränderten Viewport als auch eine sichtbare
Nicht-Pivot-Kante. ./selftest.sh selectionscroll wiederholt das mit dem Editor
aus dem gepackten App-Bundle. Der Test stellt über den produktiven
SessionStateStore ein Markdown-Dokument mit 2.200 stark unterschiedlich langen
Soft-Wrap-Zeilen wieder her, wartet auf den vollständigen Markdown-Split und
postet sechs Shift+↓-Tastaturereignisse über getrennte Runloop-Durchläufe an den
fokussierten linken Quelleditor. Vor dem verzögerten Abgleich lag die echte
Auswahlkante reproduzierbar unter dem unveränderten Viewport. Ein unmittelbarer
direkter Methodenaufruf reicht für diese Produktwirkung nicht als
Regressionsschutz.
NSTextStorageDelegate.didProcessEditing meldet editedRange im Zustand nach
der Änderung. Zusammen mit changeInLength lässt sich daraus der vorher
ersetzte Bereich berechnen. CodeEditTextView benötigte diesen alten Bereich
zum Entfernen ersetzter Layoutzeilen, invalidierte danach aber irrtümlich
erneut ihn statt des neuen Bereichs.
Bei einer reinen Einfügung hat der alte Bereich die Länge null. In einer
bereits ausgelegten langen Soft-Wrap-Zeile konnte deshalb das bisherige
Zeilenende als Fragment erhalten bleiben, obwohl die logische Zeilenlänge
schon den neuen Text enthielt. Sichtbar wirkte das Wort vollständig, aber für
seinen angehängten Teil fehlten Zeichenrechtecke: rectForOffset fiel auf ein
Nullbreiten-Rechteck zurück, textOffsetAtPoint lieferte nil, und AppKits
Fenster-Hit-Test landete im umgebenden Scroll-View. Ein Doppelklick ließ daher
die vorherige Auswahl unverändert.
Workaround (Patch 4s in build.sh): Der abgeleitete alte Bereich heißt
bewusst replacedStringRange und wird nur zum Entfernen der vorherigen
Zeilenstruktur verwendet. Nach insertNewLines invalidiert CodeEditTextView
den von NSTextStorage gelieferten neuen editedRange. Löschungen bleiben
korrekt: Ihr neuer Bereich ist leer, und CodeEdits bestehender Leerbereichs-
Pfad markiert die Zeile am Editierpunkt beziehungsweise die letzte Zeile.
Regression: ./selftest.sh wordclick stellt den realen Zustand her, indem
es in einem langen Markdown-Dokument zuerst nur den Wortanfang auslegt und den
Rest anschließend über TextView.insertText einfügt. Danach sendet der Test
echte Fensterereignisse als Doppelklick an Wortanfang und Wortende. Ein reiner
Modell- oder Frische-Layout-Test reicht nicht: Text und Zeilenlänge waren auch
im Fehlerfall korrekt, nur die alten Trefferflächen blieben bestehen.
F.19 Eine überschriebene hitTest darf hidden nicht ignorieren (2026-07-24)
AppKits Default-hitTest sortiert verborgene Views selbst aus. Wer die
Methode überschreibt, übernimmt diese Pflicht mit. MinimapView.hitTest
(CodeEditSourceEditor) beantwortete Treffer im eigenen sichtbaren Bereich
immer selbst und prüfte isHidden nie; mouseDown/mouseDragged sind dort
zusätzlich bewusst leere Überschreibungen („Eat mouse events“). Bei
ausgeblendeter Minimap — Fastras Default — lag dadurch eine unsichtbare,
rund 90 pt breite Fläche über der rechten Editorkante: Klicks und
Doppelklicks auf Text in diesem Band kamen nie im Editor an. Im
Markdown-Split fiel es zuerst auf, weil der Editor dort schmal ist und
umbrochene Zeilen bis an den Scrollbalken reichen; betroffen war aber jede
Ansicht. Die Wirkung ähnelte den älteren Trefferflächen-Fehlern (F.18),
hatte jedoch eine völlig eigene Ursache — „gleiches Symptom“ heißt bei
Klickpfaden nicht „gleiche Wurzel“.
Workaround (Patch 4t in build.sh): hitTest beginnt mit
guard !isHiddenOrHasHiddenAncestor else { return nil }.
Regression: ./selftest.sh rightedge stellt die gemeldete Geometrie
(1100×800, Seitenleiste, integrierte Vorschau, Markdown mit bis zu
646-Zeichen-Zeilen) wieder her und prüft ein ganzes Raster von Punkten bis
einen Punkt vor dem vertikalen Scrollbalken: Fenster-Hit-Test muss den
Editor treffen und textOffsetAtPoint eine Position liefern.
textOffsetAtPoint rundet wie eine Einfüge-Position: Ein Klick auf die
rechte Hälfte eines Zeichens liefert den Offset DANACH. Für den einfachen
Klick ist das korrekt. Der Doppelklick-Pfad übernahm dieselbe Position und
markierte deshalb beim Klick auf die rechte Hälfte des letzten Wortzeichens
den Leerraum hinter dem Wort. NSTextView und BBEdit wählen das Wort über die
tatsächlich getroffene Zeichenzelle.
Workaround (Patch 4u in build.sh): Liegt der Klickpunkt in der Zelle
des Vorgängerzeichens, rückt handleDoubleClick die Auswahlbasis vor
selectWord dorthin.
Regression: Der zweite Teil von ./selftest.sh rightedge doppelklickt
mit echten Fensterereignissen auf das letzte Zeichen eines Wortes an einer
Umbruchkante und verlangt exakt dieses Wort als Auswahl.
CodeEditTextView verankerte eine Maus-Selektion erst im ERSTEN
mouseDragged. Schnelle Mausbewegungen liefern grob gerasterte Events: Das
erste Drag-Ereignis kann weit vom Klickpunkt entfernt liegen, unterhalb des
Fensters wird es durch das Clamping sogar auf das Dokumentende gezogen. Die
Auswahl begann dann nicht am angeklickten Zeichen (Befund: Klick bei
Offset 10, Auswahl begann bei 2040) — sichtbar als „Auswahl springt weg /
läuft unten davon“.
Workaround (Patch 4v in build.sh): mouseDown setzt den Anker sofort
auf die geklemmte Klickposition; das erste Drag-Ereignis erweitert nur noch.
Regression: ./selftest.sh dragscroll beginnt den Drag bei Offset 10,
zieht mit echten Events 40 Punkte unter das Fenster und verlangt beides:
Autoscroll folgt nach unten UND die Auswahl bleibt am Klickpunkt verankert.
./selftest.sh selshort sichert zusätzlich die Tastaturvariante in der
kleinen, stark umbrochenen Nutzerdatei (11 Zeilen, längste 646 Zeichen).
CESEs StyledRangeContainer.applyHighlightResult dokumentiert den Vertrag
nur im Kommentar: Gelieferte Highlight-Bereiche dürfen den angefragten
Bereich nicht überschreiten. Ein Bereich, der VOR dem Chunk beginnt, wird
komplett verworfen („Skip! Overlapping“) — ohne Assertion, ohne Log. Nach
einem Edit färbt CESE ab der Editposition neu; Fastras 4D-Provider lieferte
seine gecachten Token aber ungeclippt. Ein langer /* … */-Kommentarblock
begann vor dem neu einzufärbenden Abschnitt, wurde deshalb verworfen und
verlor hinter der Editposition sichtbar die Farbe (Befund im echten
4D-Projekt: Block gefärbt bis zur Editzeile, dahinter schwarz).
Fix: FourDHighlightProvider.clippedRanges schneidet jeden Cache-
Treffer auf NSIntersectionRange mit dem angefragten Bereich zu.
Regression: ./selftest.sh comment4d editiert real in einem langen
Kommentarblock und verlangt anschließend Kommentarfarbe bis zum Blockende
(beobachtet an echten Vordergrundfarben, nicht an Provider-Ausgaben);
FourDHighlightClippingTests prüft die reine Zuschneide-Logik.
SourceEditor.updateControllerWithState scrollt bei jedem SwiftUI-Update auf
state.scrollPosition, sobald sie von der echten ClipView-Position abweicht.
Der Rückschreiber (Coordinator.textControllerScrollDidChange) läuft aber
via receive(on: RunLoop.main) erst im NÄCHSTEN Runloop. Löst ein
Tastendruck neben dem Tipp-Scroll noch ein weiteres SwiftUI-Update aus
(bei Fastra: Fußzeile mit Zeile/Spalte), gewinnt der Reconcile mit der
veralteten Position: Der frische Scroll wird zurückgedreht, der Cursor
verschwindet unter dem Fensterrand, Tippen bleibt unsichtbar. Das Race ist
timing-abhängig — auf demselben Mac im Selbsttest grün, in der echten
Nutzersitzung zu 100 % rot (Beleg: Call-Stack via Bounds-Change-Spion).
Fix (Patch 4x in build.sh): Der Scroll-Reconcile-Zweig in
SourceEditor.updateControllerWithState ist deaktiviert — Fastra steuert
Scrollen ausschließlich selbst (convergeScroll), ein externes Scroll-Soll
über den State existiert nicht. Cursor-Reconcile (externe Sprünge) bleibt
erhalten.
Sackgasse (v1.50.1, sofort wieder entfernt): Ein Fastra-seitiges
Filter-Binding mit eigenen get/set-Closures (state.scrollPosition = nil
beim Lesen) behob das Race zwar, stürzte aber in SwiftUIs
Zugriffsverfolgung ab (SIGSEGV in swift_beginAccess beim
Runloop-Observer-Flush): CESEs Coordinator hält das Binding dauerhaft und
schreibt während View-Updates hinein — das verträgt nur das echte
@State-Binding, keine handgebauten Closures.
Regression: ./selftest.sh typescroll (inkl. erzwungenem Zusatz-Update
nach jedem Tastendruck und Zweitfenster-Stufe); das Race selbst feuert unter
Testtiming nicht deterministisch — verlässlicher Beleg war Daniels
100-%-Repro vor/nach dem Fix.
Während Undo/Redo schrumpft der Text; der Zeilen-Storage des
TextLayoutManager hinkt einen Moment hinterher. Fällt in dieses Fenster
ein Scroll (macOS-„Concurrent Scrolling“-Synchronizer → updatedViewport
→ repositionCursorSelection), fragt CETV rectForOffset mit der noch
nicht geclampten alten Cursorposition. Der Bounds-Check gegen
lineStorage.length greift dann nicht, und
rangeOfComposedCharacterSequence(at:) wirft eine NSRangeException →
SIGABRT (Befund: Crash nach ⌘V + ⌘Z am Dateiende).
Workaround (Patch 4w in build.sh): Offsets außerhalb des ECHTEN
Storage liefern ein 0-breites Rechteck statt der Range-Abfrage.
Regression: TextLayoutManagerStaleOffsetTests stellt das Fenster nach
(Storage schrumpft ohne Delegate-Benachrichtigung) — ohne Patch stürzt der
Testprozess, mit Patch kommt ein ehrliches Rechteck zurück.
Eine korrekte Typeahead-Zeile beweist noch keine korrekte Darstellung nach
dem Einfügen. Fastra kannte geteilte 4D-Komponentenmethoden im
FourDComponentIndex und kennzeichnete sie im Popup mit Komponentenname und
Shippingbox. Der Highlight-Provider erhielt danach jedoch nur die
Projektmethodennamen; der eingefügte Name fiel auf .methodCall zurück und
erschien wie ein normaler Befehl. Die semantische Herkunft muss deshalb durch
die ganze Kette reisen: Workspace-Index → Provider-Cache-Schlüssel →
Tokenizer-Kategorie → Capture → Theme-Slot. Bei einer Namenskollision prüft
der Tokenizer weiterhin zuerst die Projektmethode.
Rückwärtskompatibler Patch 4m2: EditorTheme.componentMethods ist
optional und verwendet ohne Angabe commands. Damit behalten alle
vorhandenen Themes und Sprachen exakt ihre Darstellung. Der neue
CaptureName.componentMethod-Case steht am Enum-Ende; ein Einfügen in der
Mitte würde die automatisch vergebenen Raw Values vorhandener Cases ändern.
build.sh prüft jede Patchstelle hart und verwirft anschließend die
CodeEditSourceEditor-Buildprodukte.
Regressionen: Tokenizer-/Provider-Tests prüfen Kategorie, Kollision,
Refresh und Theme-Mapping. ./selftest.sh highlight4d beobachtet
Komponentenmethode, Projektmethode und Befehl im echten TextStorage in Hell
und Dunkel. ./selftest.sh completion4d übernimmt eine Shared-Component per
echtem Typeahead-Doppelklick und prüft Farbe sowie Font erst am eingefügten
Text.
TextSelectionManager.drawSelections zeichnete upstream entweder den
Hintergrund einer leeren Cursorzeile oder die Rechtecke einer nichtleeren
Auswahl. Sobald Fastra einen Suchtreffer selektierte, verschwand dadurch die
aktuelle Zeile. Der Gutter wiederholte dieselbe Einschränkung. Ein schlichtes
Entfernen der isEmpty-Prüfung ist falsch: Eine Rechteckauswahl besteht aus
mehreren Ranges und würde dann jede ihrer Zeilen als „aktuell“ einfärben;
mehrzeilige Auswahlen könnten ebenfalls uneindeutig werden.
Fix (Patch 4y in build.sh): fastraHighlightedLineOffsets trennt die
Zeilenebene von den Auswahlrechtecken. Mehrere leere Cursor markieren weiter
ihre jeweiligen Zeilen. Existiert mindestens eine echte Auswahl, liefert nur
die primäre (letzte) Range eine aktuelle Zeile; bei Tastaturauswahl bestimmt
der Gegenpol des pivot die bewegte Kante. Textfläche und Gutter verwenden
dieselbe Liste. Die Auswahlrechtecke werden danach weiterhin vollständig
gezeichnet.
Regression: SoftWrapLayoutTests.selectedRangesKeepOneCurrentLine prüft
eine rechteckartige Mehrfachauswahl, mehrere leere Cursor und eine rückwärts
aufgezogene Auswahl direkt am gepatchten TextSelectionManager.
CodeEditSourceEditor verwendete TextualIndenter.basicPatterns für jede
Sprache außer Python und Ruby. Damit galt eine .txt-Zeile wie (1,5) … als
geöffneter Klammerblock und Return fügte vier Leerzeichen ein. Entfernte man
diese von Hand und drückte erneut Return, übersprang der voreingestellte
Referenzzeilen-Filter die leere Zeile, fand wieder den Klammertext und stellte
die Einrückung erneut her.
Fix (Patch 4z2 in build.sh): Reiner Text und Markdown bekommen keine
Code-Klammermuster. Der Referenzfilter akzeptiert auch die unmittelbare leere
Vorzeile, damit sie die Einrückung bewusst auf null zurücksetzt. Echte
Code-Sprachen behalten ihre bisherigen Regeln.
Regression: ./selftest.sh mdindent lädt eine .txt-Fixture mit dem
entscheidenden (1,5)-Präfix in den gepackten Editor und prüft Return sowohl
direkt als auch nach dem manuellen Entfernen einer etwaigen Einrückung.
SwiftUIs onDrop liefert NSItemProvider, aber weder die laufende
Textposition noch kontinuierliche Randbewegungen. Ein Bild konnte daher nur an
der schon vorher vorhandenen Einfügemarke landen; eine sichtbare Drop-Marke und
stationärer Autoscroll waren auf dieser Ebene nicht implementierbar.
Fix (Patch 4z3 in build.sh): Die echte CodeEditTextView routet von Fastra
akzeptierte externe Pasteboards vor ihrem normalen Text-Drag. Sie berechnet den
Offset aus jeder NSDraggingInfo-Position, zeichnet dort den vorhandenen
CodeEdit-Cursor und scrollt mit einem kurzen Timer weiter, solange die Maus am
oberen oder unteren Viewportrand steht. Beim Loslassen setzt sie erst die
Textauswahl auf diesen Offset und ruft dann den Markdown-Assistenten. Für andere
Pasteboards bleibt der Upstream-Pfad unverändert.
Regression: ./selftest.sh mddropcursor treibt die echte
Drag-Destination mit einer Bilddatei, hält die Maus am unteren Rand und verlangt
sichtbaren Cursor, messbaren Autoscroll sowie den Link exakt am Drop-Offset.
Eine 4,36-MB-Textdatei mit nur einer logischen Zeile blockierte den Editor an
mehreren voneinander unabhängigen Stellen. Soft Wrap erzeugte tausende
Umbruchfragmente. Ohne Soft Wrap baute CoreText die gesamte Zeile als ein
einziges CTLine auf. CodeEditSourceEditor wiederholte das Layout beim
erstmaligen Anwenden bereits gesetzter Insets und Attribute. Schließlich
meldete visibleTextRange horizontal die ganze logische Zeile, sodass der
Highlighter auch unsichtbare Millionen Zeichen einplante; ein Sprachwechsel
entfernte alte Attribute synchron im ganzen Dokument.
Fix (Patches 4z4–4z12 in build.sh): Soft Wrap bleibt eingeschaltet und
frei schaltbar. CodeEditTextView hält das vollständige Fragmentmodell für
exaktes Scrollen, erzeugt aber nur Fragment-Views des sichtbaren Ausschnitts.
Ungebrochene Megazeilen werden intern an sicheren Unicode-Grenzen in
16-KiB-CTLines portioniert, lange alphanumerische Tokens ohne zeichenweise
Substring-Suche umbrochen und nur im Grafik-Ausschnitt gezeichnet. Beim ersten
Layout geht die dokumentweite Zeile direkt vom Textspeicher an CoreText, statt
dreimal vollständig als NSAttributedString kopiert und von AppKit repariert
zu werden. Die Anfangskonfiguration wird vor dem Einhängen der TextView
angewandt. Das Fenster-Einhängen und eine gleichzeitig eintreffende
Viewport-Benachrichtigung lösen keine Voll-Layouts mit einer provisorischen
Breite mehr aus. Text, Attribute und Insets treffen beim SwiftUI-Aufbau in
mehreren unmittelbar aufeinanderfolgenden Aktualisierungen ein; ein kurzer
fenstergebundener Aufschub bündelt sie vor dem ersten Umbruch, statt dieselbe
Megazeile mehrfach im selben Main-Runloop-Durchlauf auszulegen. Normales
Scrollen und spätere Layouts bleiben unmittelbar. Sichtbare Bereiche bleiben
horizontal und zwischen logischen Zeilen getrennt, und der Sprachwechsel setzt
nur dort Attribute zurück. Patch 4z11 ersetzt die einzelne Zufalls-UUID jedes
Umbruchfragments durch eine erst beim Typeset erzeugte UUID pro logischer
Zeile und einen fortlaufenden Fragmentindex. Die zusammengesetzte Kennung bleibt
für View-Reuse eindeutig, spart bei der 4,36-MB-Megazeile aber rund 50.000
Zufallszahl-Aufrufe im ersten Main-Thread-Layout. Patch 4z12 verwendet für
intern erzeugten Klartext ein kleines Objective-C-NSTextStorage-Backing,
das die von CodeEditSourceEditor vollständig gesetzten Schrift- und
Farbattribute als sofort gültig meldet. Dadurch durchsucht AppKit nicht beim
ersten CoreText-Aufbau die gesamte Megazeile erneut. Die Objective-C-Fassung
ist bewusst gewählt: Dieselbe dünne Weiterleitung in unoptimiertem Swift ließ
die Debug-Regressionstests von unter einer Sekunde auf rund sechs Sekunden
steigen. Extern übergebene Textspeicher behalten AppKits bisherigen Pfad.
Der App-Ladepfad muss dieselbe Arbeit ebenfalls begrenzen: Workspace.loadFile
publiziert den fertig aufgebauten Tab einmal statt Feld für Feld, eng
aufeinanderfolgende Footer-Statistiken werden abgebrochen und in einem
String-Durchlauf neu berechnet. Solange die Suchmaske geschlossen ist, darf ein
Tabwechsel keine gespeicherte Suchanfrage über den neuen Gesamttext starten.
Regression: DifficultDocumentCorpusTests erzeugt TXT, JSON, XML, CSV und
Markdown mit jeweils exakt 4.357.697 Byte aus einem ungültigen synthetischen
Base64-artigen Muster. Die Tests begrenzen Laden, vollständigen Editoraufbau,
Layout, JSON-Sprachwechsel, echte tree-sitter-Abfrage sowie JSON-/XML-
Formatierung. LongLineEditorPerformanceTests prüft zusätzlich View-Anzahl,
horizontal sichtbaren Bereich, Zeichendauer und Unicode-Grenzen der internen
Portionen. ./selftest.sh loadperf erzeugt prozedural eine ebenso große
.txt-Megazeile, merkt dafür wie im realen Problemfall JSON und misst den
Main-Thread-Herzschlag durch den vollständigen Workspace-, SwiftUI- und
CodeEdit-Aufbau bis eine Sekunde nach dem sichtbaren Editor. Zusätzlich müssen
der echte Editor automatisch sichtbare Fragment-Views, Soft Wrap am
Fensterrand, mehrere Umbruchfragmente und keinen horizontalen Scrollbalken
melden. Erst danach darf der Test selbst ein Kontrolllayout auslösen. Der
10-ms-Herzschlag reduziert die Messphasen-Unschärfe, ohne die 250-ms-Grenze zu
lockern. Die Fixture-Dateien entstehen nur im Test und liegen nicht im Repo.
Beleg: Absturz mit Datenverlust im Arbeitsbetrieb, Crash-Report vom
2026-08-24 — SIGTRAP in CEUndoManager.registerMutation mit „CFString cannot
be created from a negative number of bytes", ausgelöst durch einen ganz
normalen Tastendruck.
TextSelection.pivot ist der feste Anker, um den Shift+Pfeil eine Auswahl
dreht. Upstream setzt ihn nur beim ERSTEN Shift-Schritt (pivot == nil) und
verwirft ihn nie wieder: didReplaceCharacters verschiebt nach einem Edit
nur die Range, der Anker bleibt auf dem alten Text stehen. Nach einem
Backspace zeigt er dadurch RECHTS neben den Cursor. Das nächste Shift+→
landet in updateSelectionRange im Schrumpf-Zweig (pivot > location,
length -= range.length) und rechnet aus der leeren Auswahl {9, 0} den
Bereich {10, -1} aus.
Die negative Länge fällt durch ALLE Bereichs-Wächter des Pakets: Sowohl
substring(from:) (TextStory) als auch die setSelectedRange(s)-Prüfungen
testen nur location ≥ 0 und max ≤ length — bei negativer Länge liegt
max UNTER location, beides geht auf. Erst -[NSString substringWithRange:]
reicht die Länge als CFRange an CoreFoundation weiter, und die App bricht ab.
Ein Bereichs-Wächter ist also erst vollständig mit length >= 0.
Fix (build.sh 4z14, drei Schichten): didReplaceCharacters setzt den Anker
jedes betroffenen Selektionsobjekts auf nil (die nächste Shift-Auswahl
beginnt am aktuellen Cursor — das ist auch das erwartete Verhalten);
replaceCharacters überspringt korrupte Bereiche, statt sie dem
Undo-Manager zu reichen; beide Auswahl-Setter weisen negative Längen ab.
Regression: StaleSelectionPivotTests spielt das Nutzer-Szenario
Shift-Auswahl → Backspace → Shift+→ → Tippen über die öffentlichen APIs
durch; gegen den unkorrigierten Stand starb der Testprozess mit Signal 5.