From 0996b12204b1988037c5c558681d93259643a4dd Mon Sep 17 00:00:00 2001 From: necessarylion Date: Thu, 30 Jul 2026 08:26:18 +0700 Subject: [PATCH 1/2] =?UTF-8?q?A=20long=20line=20you=20can=20reach,=20and?= =?UTF-8?q?=20=E2=8C=A5Z=20to=20fold=20it=20away?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A line that ran off the right edge could not be reached.** With wrapping off the editor drew the whole line and then refused to scroll sideways to any of it, while scrolling down worked perfectly — which is the shape of the bug, because the height is measured somewhere else entirely. The width is measured in CodeEditTextView, and the measurement is thrown away: private func layoutLine(…, maxFoundLineWidth: inout CGFloat) … { … var maxFoundLineWidth = maxFoundLineWidth // shadows the parameter … if maxFoundLineWidth < lineSize.width { maxFoundLineWidth = lineSize.width } } That `var` shadows the `inout` parameter with a local copy, so every line's width is written into the copy and dropped when the method returns. `maxLineWidth` never leaves zero, `estimatedWidth()` is therefore the gutter's inset alone, and `updateFrameIfNeeded` sizes the document to `max(inset, pane width)` — never wider than the pane, so the scroll view has nothing to scroll and the rest of the line is simply cut off. So the measurement is made here instead, from the same numbers: `WidenDocumentForLongLines` takes the same route `ClipFloatingSubviews` already takes for the same kind of reason — a coordinator, and the controller it is handed. The widest line fragment actually laid out, plus the same insets, written to the text view's frame. Only the widest *laid-out* line, because that is all the package itself would ever have known: the width grows as the reader scrolls into longer lines and never shrinks, which is the behaviour `maxLineWidth` was written to have. **It has to be re-applied rather than set once**, because the package puts its own too-narrow number back on every scroll — so the two notifications are exactly the two moments it does that: the clip view's bounds moving, and the frame being reset underneath. **Both observers run synchronously, and that is not a detail.** Handed `.main` the block is an operation on the main queue and lands a turn later, so the narrow frame is live when the scroll view next tiles — and a clip view whose document has just shrunk under it clamps its origin back to zero. The reader would be thrown back to column one every time they scrolled down. Widening from inside the setter means the narrow width never survives long enough to be acted on. Wrapping on is left alone entirely: there the pane's width is the answer and a wide document would be the bug. And the whole thing goes quiet by itself if this is ever fixed upstream, because the frame is only widened when the package has left it narrower than the text needs. **⌥Z folds a long line into the pane, and the same key lets it run off the edge again.** VS Code's key for it, which is the one a reader coming from there tries first. It is a `ShortcutAction` rather than a key literal, so the Editor menu's Wrap Lines item wears it and Settings → Shortcuts can move it or take it away — there is a new **Editor** group holding it. Checked: the defect and the fix were both run against the real pinned package in a headless harness. Before, with a 6149pt line laid out: `estimatedWidth=40`, document 600 wide, and the clip view refusing any horizontal origin at all (`constrainBoundsRect` clamps x=400 to 0). After the coordinator's measurement: document 6189, x=400 reachable, up to 5589. Then the package's own `updateFrameIfNeeded` puts 600 back, and re-applying restores it — which is the loop the notifications close. With wrapping on the frame is untouched. `Scripts/run.sh` — Debug build succeeded, no warnings, app launches. Not checked by me: the scrolling as felt under a trackpad, which is the user's screen. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QZeMBBySiGJmqM81eXurS3 --- README.md | 2 + Sources/Workspace/Editor/CodeEditorView.swift | 154 +++++++++++++++++- .../Workspace/Models/KeyboardShortcuts.swift | 12 +- Sources/Workspace/WorkspaceApp.swift | 1 + 4 files changed, 167 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a323c6e..38ee09b 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ missing ones, so you never have to work out which command it wanted. | Feature | What it does | | --- | --- | | Real editing | Tree-sitter colours, folding, multiple cursors, find and replace (⌘F), auto-indent. | +| Wrap or don't | ⌥Z folds a long line into the pane, or lets it run off the edge and scroll sideways. | | Change markers | What has changed since HEAD, down the edge of the gutter — green where lines were added, yellow where they were changed, and a red wedge where they were deleted. | | Language servers | Diagnostics, completions (⌃Space) and ⌘-click to definition for 20+ languages. | | Go to file (⌘P) | `item controller` finds `app/controllers/item_controller.rb`. Punctuation optional. | @@ -170,6 +171,7 @@ everything macOS binds) are not on offer there. | ⇧⌘O | Add a repository folder you already have | | ⌘P | Go to file | | ⌘F | Find in the file | +| ⌥Z | Wrap long lines, and the same key back to scrolling sideways | | ⌘S | Save the file — or the PR description — you are editing | | ⇧⌘E | Save the Markdown preview as a PDF | | ⌘[ / ⌘] | Back / forward | diff --git a/Sources/Workspace/Editor/CodeEditorView.swift b/Sources/Workspace/Editor/CodeEditorView.swift index 31feb3c..ce8ce1f 100644 --- a/Sources/Workspace/Editor/CodeEditorView.swift +++ b/Sources/Workspace/Editor/CodeEditorView.swift @@ -71,6 +71,7 @@ private struct EditorPane: View { /// Held rather than made in `body`: a coordinator is handed over by /// reference and has to outlive the render that passed it. @State private var clipping = ClipFloatingSubviews() + @State private var widening = WidenDocumentForLongLines() @State private var finding = ScrollToFindMatch() @State private var revealing: RevealPendingPosition @State private var reloading: ReloadTextInPlace @@ -172,7 +173,7 @@ private struct EditorPane: View { /// Spelled out rather than built from a literal: the two have no type in /// common but the protocol, and an array literal of them infers the wrong one. private var coordinators: [any TextViewCoordinator] { - var list: [any TextViewCoordinator] = [clipping, finding, revealing, reloading] + var list: [any TextViewCoordinator] = [clipping, widening, finding, revealing, reloading] if let gitMarkers { list.append(gitMarkers) } if let languageServer { list.append(languageServer) } return list @@ -486,6 +487,157 @@ private final class ClipFloatingSubviews: TextViewCoordinator { } } +/// Makes a line that runs off the right edge reachable, which the editor cannot +/// do for itself. +/// +/// With wrapping off the document should be as wide as its widest line, and +/// `TextView.updateFrameIfNeeded` sizes it to +/// `max(layoutManager.estimatedWidth(), viewport width)` — where +/// `estimatedWidth()` is `maxLineWidth + edgeInsets.horizontal`. In the pinned +/// version of CodeEditTextView `maxLineWidth` never leaves zero, because the +/// method that measures it throws the measurement away: +/// +/// private func layoutLine(…, maxFoundLineWidth: inout CGFloat) … { +/// … +/// var maxFoundLineWidth = maxFoundLineWidth // shadows the parameter +/// … +/// if maxFoundLineWidth < lineSize.width { maxFoundLineWidth = lineSize.width } +/// } +/// +/// That `var` shadows the `inout` parameter with a local copy, so every line's +/// width is written into the copy and dropped when the method returns. So +/// `estimatedWidth()` is the gutter's inset alone, the document is never wider +/// than the pane, and the scroll view has nothing to scroll: a 300-column line is +/// cut off at the right edge with no way to reach the rest of it — while a +/// vertical scroll works perfectly, because the height is measured somewhere +/// else. +/// +/// So the measurement is made here instead, from the same numbers: the widest +/// line fragment the layout manager has actually laid out, plus the same insets, +/// written to the text view's frame. Only the widest *laid-out* line, because +/// that is all the package itself would ever have known — the width grows as the +/// reader scrolls into longer lines, and never shrinks, which is the behaviour +/// `maxLineWidth` was written to have. +/// +/// **It has to be re-applied rather than set once.** Every scroll and every +/// layout pass runs `updateFrameIfNeeded` again and puts the too-narrow number +/// back, so the two notifications below are exactly the two moments it does +/// that: the clip view's bounds moving, and the frame being reset under us. +/// +/// **And it goes quiet on its own if this is ever fixed upstream**, because the +/// frame is only ever widened when the package has left it narrower than the text +/// needs. Wrapping on is left alone entirely — there the pane's width is the +/// answer, and a wide document would be the bug. +private final class WidenDocumentForLongLines: TextViewCoordinator, @unchecked Sendable { + private weak var controller: TextViewController? + /// The widest fragment seen so far, in the same terms as the layout manager's + /// own `maxLineWidth`: text only, before the gutter's inset is added back. + private var widest: CGFloat = 0 + private var scrollObserver: (any NSObjectProtocol)? + private var frameObserver: (any NSObjectProtocol)? + /// Set while the frame is being written, so the notification that write + /// causes is not answered with another write. + private var isApplying = false + + func prepareCoordinator(controller: TextViewController) { + MainActor.assumeIsolated { install(in: controller) } + } + + /// Again once the views are really on screen, for the reason + /// ``ClipFloatingSubviews`` gives: `loadView` replaces the scroll view after + /// `prepareCoordinator` has run, and the observers below name the old one. + func controllerDidAppear(controller: TextViewController) { + MainActor.assumeIsolated { install(in: controller) } + } + + /// Text that changed is text whose lines have new widths. The frame follows + /// on its own most of the time — a line added or removed changes the height — + /// but typing into one long line changes nothing else at all. + func textViewDidChangeText(controller: TextViewController) { + MainActor.assumeIsolated { apply() } + } + + func destroy() { + MainActor.assumeIsolated { + removeObservers() + controller = nil + } + } + + @MainActor + private func install(in controller: TextViewController) { + self.controller = controller + removeObservers() + guard let scrollView = controller.scrollView, let textView = controller.textView else { return } + + // Both with no queue, which means both run *synchronously* inside the + // change that posted them — and that is not a detail. Handed `.main` + // instead, the block is an operation on the main queue and lands a turn + // later: the too-narrow frame would be live when the scroll view next + // tiled, and a clip view whose document has just shrunk under it clamps + // its origin back to zero. The reader would be thrown back to column one + // every time they scrolled down. Widening again from inside the setter + // means the narrow width never survives to be acted on. + // + // Scrolling down lays out lines that were never measured, and one of them + // may be longer than everything above it. + scrollObserver = NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: nil + ) { [weak self] _ in + MainActor.assumeIsolated { self?.apply() } + } + // The package resetting the width — which it does on every scroll, and is + // the reason one call is not enough. + frameObserver = NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: textView, + queue: nil + ) { [weak self] _ in + MainActor.assumeIsolated { self?.apply() } + } + apply() + } + + @MainActor + private func removeObservers() { + for observer in [scrollObserver, frameObserver].compactMap({ $0 }) { + NotificationCenter.default.removeObserver(observer) + } + scrollObserver = nil + frameObserver = nil + } + + @MainActor + private func apply() { + guard !isApplying, + let controller, + !controller.wrapLines, + let textView = controller.textView, + let scrollView = controller.scrollView + else { return } + + guard let layoutManager = textView.layoutManager else { return } + let visible = textView.visibleRect + for line in layoutManager.lineStorage.linesStartingAt(visible.minY, until: visible.maxY) { + for fragment in line.data.lineFragments { + widest = max(widest, fragment.data.width) + } + } + + // The same sum `estimatedWidth()` makes, and the same floor + // `updateFrameIfNeeded` applies: never narrower than the pane, or a short + // file would stop drawing its background halfway across. + let needed = max(widest + layoutManager.edgeInsets.horizontal, scrollView.contentSize.width) + guard textView.frame.width < needed else { return } + + isApplying = true + textView.frame.size.width = needed + isApplying = false + } +} + /// Brings the find panel's current match on screen. /// /// Cmd+F, a query, Enter — and the selection moves to the next match while the diff --git a/Sources/Workspace/Models/KeyboardShortcuts.swift b/Sources/Workspace/Models/KeyboardShortcuts.swift index a998902..1514e34 100644 --- a/Sources/Workspace/Models/KeyboardShortcuts.swift +++ b/Sources/Workspace/Models/KeyboardShortcuts.swift @@ -108,6 +108,8 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable { case close case savePDF + case toggleWrap + case goToFile case goBack case goForward @@ -133,6 +135,7 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable { case .save: "Save" case .close: "Close" case .savePDF: "Save as PDF" + case .toggleWrap: "Wrap Lines" case .goToFile: "Go to File" case .goBack: "Back" case .goForward: "Forward" @@ -156,6 +159,7 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable { case .save: "Write the open file — or the pull request description — back" case .close: "Close whatever the viewer is showing" case .savePDF: "Save the Markdown preview as a PDF" + case .toggleWrap: "Fold a long line into the pane, or let it run off the edge and scroll" case .goToFile: "Find a file in the selected repository by name" case .goBack: "The item you were looking at before" case .goForward: "The item you came back from" @@ -175,6 +179,8 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable { .repositories case .save, .close, .savePDF: .file + case .toggleWrap: + .editor case .goToFile, .goBack, .goForward: .go case .askClaude, .toggleTerminal, .openTerminal, .newTerminalTab, .newHomeTerminal: @@ -194,6 +200,9 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable { case .save: KeyChord("s", [.command]) case .close: KeyChord("w", [.command, .shift]) case .savePDF: KeyChord("e", [.command, .shift]) + // VS Code's key for it, which is the one a reader coming from there tries + // first. ⌥ alone rather than with ⌘ for the same reason. + case .toggleWrap: KeyChord("z", [.option]) case .goToFile: KeyChord("p", [.command]) case .goBack: KeyChord("[", [.command]) case .goForward: KeyChord("]", [.command]) @@ -210,7 +219,7 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable { /// How the list in Settings is broken up — the menus the keys mostly live in. enum ShortcutGroup: String, CaseIterable, Identifiable { - case repositories, file, go, terminal, writing + case repositories, file, editor, go, terminal, writing var id: String { rawValue } @@ -218,6 +227,7 @@ enum ShortcutGroup: String, CaseIterable, Identifiable { switch self { case .repositories: "Repositories" case .file: "File" + case .editor: "Editor" case .go: "Go" case .terminal: "Terminal and Claude" case .writing: "Writing" diff --git a/Sources/Workspace/WorkspaceApp.swift b/Sources/Workspace/WorkspaceApp.swift index fe5db53..66b9d77 100644 --- a/Sources/Workspace/WorkspaceApp.swift +++ b/Sources/Workspace/WorkspaceApp.swift @@ -174,6 +174,7 @@ struct WorkspaceApp: App { get: { store.wrapsLines }, set: { store.wrapsLines = $0 } )) + .shortcut(.toggleWrap) Toggle("Preview Markdown", isOn: Binding( get: { store.markdownPreview }, set: { store.markdownPreview = $0 } From 1d483bf2b242f0ba388b12dbc7036f60f2521e89 Mon Sep 17 00:00:00 2001 From: necessarylion Date: Thu, 30 Jul 2026 08:36:14 +0700 Subject: [PATCH 2/2] Name the text view we reach into, and pin it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WidenDocumentForLongLines` walks CodeEditTextView's layout internals — `lineStorage`, a line's `lineFragments`, a fragment's `width`, the layout manager's `edgeInsets` — and that package was reaching us transitively through CodeEditSourceEditor, which is to say nothing in the manifest promised any version of it at all. Three of our files import it directly (the coordinator, the gutter markers and the language server join), so it is named here now and pinned to the version already resolved. `exact: "0.12.1"` narrows the `from: "0.12.1"` CodeEditSourceEditor 0.15.2 asks for rather than contradicting it, so `Package.resolved` does not move — verified byte-identical. The pin is the point: internal layout can be rearranged in a patch release, the compiler only notices if a name changes, and raising it is an audit of that coordinator rather than a routine update. Raised by CodeRabbit on #20. Checked: `Scripts/bundle.sh` — build succeeded, `Package.resolved` unchanged. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QZeMBBySiGJmqM81eXurS3 --- Package.swift | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index b60fa8e..7733b55 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,25 @@ let package = Package( // tree-sitter highlighter); this replaces it wholesale, and is used with // its own defaults — its highlighting, its gutter, its find panel. .package(url: "https://github.com/CodeEditApp/CodeEditSourceEditor.git", exact: "0.15.2"), + // The text view that editor is built on. It would arrive transitively + // through CodeEditSourceEditor regardless; it is named here because our + // own sources `import CodeEditTextView` and one of them reaches past the + // editor's public surface into this package's layout internals. + // `WidenDocumentForLongLines` in `Editor/CodeEditorView.swift` walks + // `layoutManager.lineStorage`, reads each line's `lineFragments` and each + // fragment's `width`, and adds `layoutManager.edgeInsets` back, so that an + // unwrapped long line can be scrolled to its end — none of which the + // package offers an API for. A transitive dependency is one nothing in + // this manifest promises, and the version that satisfies it can move + // whenever the editor's own requirement is re-resolved. + // + // Pinned exactly, to the version already in Package.resolved. + // CodeEditSourceEditor 0.15.2 asks for `from: "0.12.1"`, so this narrows + // that range rather than contradicting it. The pin is deliberate: raising + // it is an audit of that coordinator, not a routine update, because + // internal layout can be rearranged in a patch release and the compiler + // is the only thing that would notice — and only if the names change. + .package(url: "https://github.com/CodeEditApp/CodeEditTextView.git", exact: "0.12.1"), // The terminal engine. Upstream ghostty ships no reusable framework, so // this package supplies libghostty as a prebuilt universal // (arm64 + x86_64) xcframework that SwiftPM downloads and @@ -36,7 +55,8 @@ let package = Package( // still yields ghostty_app_t, ghostty_surface_new and friends. .product(name: "GhosttyKit", package: "libghostty-spm"), .product(name: "CodeEditLanguages", package: "CodeEditLanguages"), - .product(name: "CodeEditSourceEditor", package: "CodeEditSourceEditor") + .product(name: "CodeEditSourceEditor", package: "CodeEditSourceEditor"), + .product(name: "CodeEditTextView", package: "CodeEditTextView") ], path: "Sources/Workspace", // The diagram renderers' HTML hosts with their bundled scripts, and