Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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 |
Expand Down
154 changes: 153 additions & 1 deletion Sources/Workspace/Editor/CodeEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down
12 changes: 11 additions & 1 deletion Sources/Workspace/Models/KeyboardShortcuts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ enum ShortcutAction: String, CaseIterable, Identifiable, Sendable {
case close
case savePDF

case toggleWrap

case goToFile
case goBack
case goForward
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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:
Expand All @@ -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])
Expand All @@ -210,14 +219,15 @@ 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 }

var title: String {
switch self {
case .repositories: "Repositories"
case .file: "File"
case .editor: "Editor"
case .go: "Go"
case .terminal: "Terminal and Claude"
case .writing: "Writing"
Expand Down
1 change: 1 addition & 0 deletions Sources/Workspace/WorkspaceApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Loading