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 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 }