diff --git a/CHANGELOG.md b/CHANGELOG.md index 054257c32..352169cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Every database operation TablePro authorizes is written to a local execution log, including the ones the AI assistant and MCP clients ask for, with the statement stored as a digest rather than as text. Records are hash chained, so an edited, reordered or removed entry can be detected. The log stays on the Mac and is not synced. - An administrator can set a minimum Safe Mode level for every connection through a macOS configuration profile, so a managed Mac cannot be dropped below it. A connection set stricter keeps its own level, since the policy is a floor rather than a ceiling. The control shows as managed instead of editable. - Plugins signed by other developers can be installed. TablePro used to refuse any plugin bundle it had not signed itself, so the only way to publish a driver was through the TablePro repository. A bundle signed with an Apple Developer ID now installs after you agree to trust that developer by name, and the prompt says plainly that a database plugin runs as part of TablePro and can read the credentials of every connection you open. Trust is recorded per developer rather than per plugin, so their updates install without asking again, and you can withdraw it. Unsigned and ad-hoc signed bundles are still refused. +- The SQL editor folds code. Move the pointer over the gutter and a chevron appears beside every statement, table body, CTE, subquery, `BEGIN` block and multi-line comment that spans more than one line, with a bar marking how far the one under the pointer reaches; click the chevron to collapse that block. A folded block keeps its chevron so you can find it again, and a gutter you are not pointing at shows nothing but line numbers. A collapsed region is replaced by a chip showing the start of what it hides and how many lines are hidden, so you can read a long script without scrolling through the parts you are not working on. Click the chip to bring it back, or rest the pointer on it in the query editor to peek at the whole block, opening line included and syntax highlighted, without expanding it. Pointing at a chip also marks its chevron in the gutter, since the two are the same control. The chevrons are reachable with VoiceOver, which reads each one as a disclosure control and says whether its block is folded. Fold All, Unfold All and Toggle Fold are in the Query menu with rebindable shortcuts, and Fold and Unfold are on the editor's right-click menu. Collapsed regions are remembered when a tab is closed and reopened. Folding also works on the DDL and trigger views, the SQL import preview, the AI review sheet, AI chat code blocks and the JSON cell viewer, which number their lines while folding is on so the chevrons never sit in a blank rail. Turning line numbers off hides the chevrons with them, and Fold All, Unfold All, Toggle Fold and the collapsed chips keep working. Turn folding off in Settings > Editor. - `Cmd+F` on a table tab opens a find bar over the results. Type a term and the matching cell is highlighted and scrolled to; `Return` and `Cmd+G` step forward, `Cmd+Shift+G` steps back, `Escape` clears the term and then closes the bar. Matching ignores case and accents and runs over the text as displayed, skipping binary and spatial columns. The counter always says what it searched, reading "3 of 12 on this page" while rows remain unfetched and "3 of 12" once everything is loaded, so a result is never mistaken for an answer about the whole table. When nothing matches on the page and more rows exist, Search All Rows turns the term into a server-side filter. - The Execute button's menu in the SQL editor offers Execute All Statements, alongside the `Cmd+Shift+Enter` shortcut and the Query menu. Running every statement in a tab no longer means selecting the whole tab first. (#2230) - Double-click a table in the object browser, or press `Return` on it, to keep its tab. Clicking a table opens it in a preview tab that the next click reuses, and until now nothing said "keep this one", so browsing several tables left them all fighting over the first tab. Keeping a tab means the next table you click opens in its own, and double-clicking a table that is already open switches back to its tab rather than making a second one. (#2235) diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Folding.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Folding.swift new file mode 100644 index 000000000..3b67074bd --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Folding.swift @@ -0,0 +1,174 @@ +// +// TextViewController+Folding.swift +// CodeEditSourceEditor +// + +import AppKit + +/// A collapsed fold the pointer is over, and where its placeholder is drawn. +public struct CollapsedFoldHit: Equatable { + /// The range of text the placeholder hides. + /// + /// A fold starts at the end of the line that opens it, so this range excludes `CREATE TABLE users (` and starts + /// at the newline after it. + public let hiddenRange: Range + + /// The whole block the fold belongs to, grown to line boundaries. + /// + /// A reader peeking at a fold wants the block, not the remainder of it, so this range covers the line that opens + /// the block through the end of the line that closes it. + public let blockRange: Range + + /// The placeholder's rect in the text view's coordinate space, for anchoring a preview. + public let rect: CGRect +} + +public extension TextViewController { + /// Posted on the controller whenever a fold collapses or expands. + static let foldStateDidChangeNotification = Notification.Name( + "CodeEditSourceEditor.foldStateDidChangeNotification" + ) + + /// The collapsed fold under a point in the text view, if there is one. + /// + /// Every character a placeholder hides is drawn as that one placeholder, so a point that resolves to an offset + /// inside the folded range is a point over the placeholder. Text after the placeholder resolves past the range. + func collapsedFold(at point: CGPoint) -> CollapsedFoldHit? { + placeholderHover(at: point)?.hit + } + + /// The document's plain text over a range, capped so reading a huge region stays cheap. + /// + /// The text arrives unstyled on purpose. Attributes in the text storage only exist for ranges the highlighter has + /// laid out, and a collapsed fold is by definition not laid out, so anything reading them back would style the + /// lines the reader has already seen and leave the rest plain. A caller that wants the text highlighted has to + /// highlight it itself. + func documentText(in range: Range, limit: Int) -> String? { + guard let storage = textView?.textStorage else { return nil } + let start = max(0, min(range.lowerBound, storage.length)) + let end = max(start, min(range.upperBound, storage.length)) + guard end > start else { return nil } + return (storage.string as NSString).substring(with: NSRange(location: start, length: min(end - start, limit))) + } + + /// The range of every fold in the document, collapsed or not, ordered by start position. + var foldRanges: [Range] { + foldModel?.getFolds(in: textView.documentRange.intRange).map(\.range) ?? [] + } + + /// The ranges of every collapsed fold, suitable for persisting and replaying with ``restoreCollapsedFolds(_:)``. + var collapsedFoldRanges: [Range] { + foldModel?.collapsedFoldRanges() ?? [] + } + + /// Collapses the outermost folds in the document. + func foldAll() { + foldModel?.setAllCollapsed(true) + } + + /// Expands every collapsed fold in the document. + func unfoldAll() { + foldModel?.setAllCollapsed(false) + } + + /// Collapses the fold at a line, or expands it when it is already collapsed. + /// - Parameter lineNumber: The line to toggle, zero-indexed. + /// - Returns: Whether a fold was found at that line. + @discardableResult + func toggleFold(atLine lineNumber: Int) -> Bool { + guard let model = foldModel, let fold = model.getCachedFoldAt(lineNumber: lineNumber) else { return false } + model.setCollapsed(!model.isCollapsed(fold), for: fold) + return true + } + + /// Toggles the innermost fold containing the cursor. + /// - Returns: Whether a fold was found at the cursor. + @discardableResult + func toggleFoldAtCursor() -> Bool { + guard let offset = cursorPositions.first?.range.location, + let line = textView.layoutManager.textLineForOffset(offset) else { return false } + return toggleFold(atLine: line.index) + } + + /// Whether a fold containing the cursor is currently collapsed. `nil` when there is no fold at the cursor. + func foldStateAtCursor() -> Bool? { + guard let model = foldModel, + let offset = cursorPositions.first?.range.location, + let line = textView.layoutManager.textLineForOffset(offset), + let fold = model.getCachedFoldAt(lineNumber: line.index) else { return nil } + return model.isCollapsed(fold) + } + + /// Collapses the folds matching the given ranges once they have been calculated. + /// + /// Ranges that no longer match a fold are dropped, so a document edited outside the editor restores what it can + /// instead of failing. + func restoreCollapsedFolds(_ ranges: [Range]) { + foldModel?.restoreCollapsedFolds(ranges) + } + + internal var foldModel: LineFoldModel? { + gutterView?.foldingRibbon.model + } +} + +// MARK: - Hover + +internal extension TextViewController { + /// The collapsed fold's placeholder under a point, with the fold it stands for. + /// + /// The placeholder carries its own fold, so resolving the pointer to an attachment resolves it to a fold too. No + /// second lookup against the cache is needed, and the two can never disagree about which fold was hit. + func placeholderHover(at point: CGPoint) -> LineFoldModel.PlaceholderHover? { + guard let textView, let layoutManager = textView.layoutManager, + let offset = layoutManager.textOffsetAtPoint(point) else { return nil } + + let overlapping = layoutManager.attachments.getAttachmentsOverlapping(NSRange(location: offset, length: 1)) + guard let attachment = overlapping.first(where: { $0.attachment is LineFoldPlaceholder }), + let placeholder = attachment.attachment as? LineFoldPlaceholder, + let caret = layoutManager.rectForOffset(attachment.range.location) else { return nil } + + // `rectForOffset` reports a caret, which has no width. The placeholder's own width makes the hit a rect that + // covers what the reader sees, which is what a popover needs to anchor to. + let hidden = attachment.range.intRange + return LineFoldModel.PlaceholderHover( + fold: placeholder.fold, + hit: CollapsedFoldHit( + hiddenRange: hidden, + blockRange: blockRange(covering: hidden), + rect: CGRect( + x: caret.minX, + y: caret.minY, + width: placeholder.width, + height: caret.height + ) + ) + ) + } + + /// Tells every coordinator which collapsed fold the pointer came to rest on, or that it left one. + func foldHoverDidChange(_ hit: CollapsedFoldHit?) { + for coordinator in textCoordinators.values() { + coordinator.textViewDidChangeHoveredFold(controller: self, hit: hit) + } + } + + /// Grows a range to cover whole lines. + /// + /// This reads the string rather than the layout manager because the lines inside a collapsed fold have no layout + /// to ask: the fold hides them by zeroing their heights, and the layout manager resolves offsets through visible + /// positions only. `lineRange(for:)` answers from the text itself, so it is correct whether or not a line has + /// ever been laid out. + func blockRange(covering range: Range) -> Range { + guard let storage = textView?.textStorage, storage.length > 0 else { return range } + let string = storage.string as NSString + let lower = max(0, min(range.lowerBound, string.length)) + let upper = max(lower, min(range.upperBound, string.length)) + + let first = string.lineRange(for: NSRange(location: lower, length: 0)) + let last = upper > lower + ? string.lineRange(for: NSRange(location: max(lower, upper - 1), length: 0)) + : first + return first.location.. [LineFoldProviderLineInfo] + + /// The label drawn in place of a collapsed region. + /// + /// The provider owns this string because it is the only part of the folding system the host application supplies, + /// so it is the only part that can reach the application's localized strings. + func foldPlaceholderLabel(for summary: FoldPlaceholderSummary) -> String +} + +public extension LineFoldProvider { + func foldPlaceholderLabel(for summary: FoldPlaceholderSummary) -> String { + summary.previewText + } } diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift index aa6c9ace8..98ee04533 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/FoldRange.swift @@ -13,8 +13,4 @@ struct FoldRange: Sendable, Equatable { let depth: Int let range: Range var isCollapsed: Bool - - func isHoveringEqual(_ other: FoldRange) -> Bool { - depth == other.depth && range.contains(other.range) - } } diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift index 939d27dc3..a6e71ab76 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldCalculator.swift @@ -59,6 +59,11 @@ actor LineFoldCalculator { private func buildFoldsForDocument() async { guard let controller = self.controller, let foldProvider = self.foldProvider else { return } let documentRange = await controller.textView.documentRange + let sizeLimit = await controller.configuration.peripherals.foldingSizeLimit + guard documentRange.length <= sizeLimit else { + valueStreamContinuation.yield(LineFoldStorage(documentLength: documentRange.length)) + return + } var foldCache: [LineFoldStorage.RawFold] = [] // Depth: Open range var openFolds: [Int: LineFoldStorage.RawFold] = [:] @@ -165,7 +170,6 @@ actor LineFoldCalculator { mutating func next() -> [LineFoldProviderLineInfo]? { var results: [LineFoldProviderLineInfo] = [] var count = 0 - var previousDepth: Int = previousDepth while count < 50, let linePosition = textIterator.next() { let foldInfo = foldProvider.foldLevelAtLine( lineNumber: linePosition.index, diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift index 0674bbba4..b920dd624 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldModel.swift @@ -17,6 +17,7 @@ import Combine /// /// For fold storage and querying, see ``LineFoldStorage``. For fold calculation see ``LineFoldCalculator`` /// and ``LineFoldProvider``. For drawing see ``LineFoldRibbonView``. +@MainActor class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject { static let emphasisId = "lineFolding" @@ -28,6 +29,8 @@ class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject { private var textChangedStream: AsyncStream private var textChangedStreamContinuation: AsyncStream.Continuation private var cacheListenTask: Task? + private var pendingRestoreRanges: Set>? + private var placeholderTracker: LineFoldPlaceholderTracker? weak var controller: TextViewController? weak var foldView: NSView? @@ -35,7 +38,9 @@ class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject { init(controller: TextViewController, foldView: NSView) { self.controller = controller self.foldView = foldView - (textChangedStream, textChangedStreamContinuation) = AsyncStream.makeStream() + (textChangedStream, textChangedStreamContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) self.calculator = LineFoldCalculator( foldProvider: controller.foldProvider, controller: controller, @@ -44,19 +49,213 @@ class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject { super.init() controller.textView.addStorageDelegate(self) - cacheListenTask = Task { @MainActor [weak foldView] in + let calculator = self.calculator + cacheListenTask = Task { @MainActor [weak self, weak foldView] in for await newFolds in await calculator.valueStream { - foldCache = newFolds + guard let self else { return } + self.foldCache = newFolds foldView?.needsDisplay = true + self.applyPendingRestoreIfPossible() } } textChangedStreamContinuation.yield(Void()) + placeholderTracker = LineFoldPlaceholderTracker(model: self) } func getFolds(in range: Range) -> [FoldRange] { foldCache.folds(in: range) } + /// Drops every fold the outgoing document had. + /// + /// A recalculation deliberately carries collapse state across by depth and start offset, which is what keeps a + /// region folded while the reader types above it. Replacing the document makes those offsets meaningless, so + /// without this one document's collapsed regions reappear in the next wherever they happen to line up. + /// + /// Nothing is posted. This is not the reader collapsing or expanding anything, and there is no document left for + /// a listener to describe. + func documentDidReplace() { + gutterHover = nil + placeholderHover = nil + hoveredFold = nil + foldCache = LineFoldStorage(documentLength: controller?.textView.documentRange.length ?? 0) + pendingRestoreRanges = nil + refresh() + } + + /// Releases what outlives the view hierarchy on its own. + /// + /// The pointer tracking's area names this model's tracker as its owner and must not outlive it, and the task + /// listening to the calculator awaits a stream that never ends, so neither is released by the editor's views + /// going away. + func destroy() { + placeholderTracker?.destroy() + placeholderTracker = nil + cacheListenTask?.cancel() + cacheListenTask = nil + } + + // MARK: - Collapse + + /// Collapses or expands a fold, adding or removing its placeholder to match. + func setCollapsed(_ isCollapsed: Bool, for fold: FoldRange) { + guard let controller, let layoutManager = controller.textView?.layoutManager else { return } + guard isCollapsed != self.isCollapsed(fold) else { return } + + if isCollapsed { + guard let placeholder = makePlaceholder(for: fold, controller: controller) else { return } + layoutManager.attachments.add(placeholder, for: NSRange(fold.range)) + } else if let attachment = attachment(for: fold) { + layoutManager.attachments.remove(atOffset: attachment.range.location) + } + + commitCollapseChange(for: fold) + } + + /// The single place a fold's collapsed state is recorded and published. + /// + /// Every route that collapses or expands a fold ends here, so the cache, the layout, the gutter and anything + /// listening for the notification can never disagree about what is folded. The two routes differ only in who + /// removes the placeholder: the gutter's chevron asks the layout manager to, and a click on the placeholder has + /// already had it removed by the attachment manager before the delegate is told. + private func commitCollapseChange(for fold: FoldRange) { + foldCache.toggleCollapse(forFold: fold) + controller?.textView?.needsLayout = true + controller?.gutterView?.needsDisplay = true + foldView?.needsDisplay = true + guard let controller else { return } + NotificationCenter.default.post( + name: TextViewController.foldStateDidChangeNotification, + object: controller + ) + } + + /// Collapses or expands every fold at once. + /// + /// Collapsing is limited to the outermost folds because the layout manager ignores an attachment that overlaps an + /// earlier one, so collapsing a fold and its children together would leave the children's placeholders unlaid out. + func setAllCollapsed(_ isCollapsed: Bool) { + guard let controller else { return } + let documentRange = controller.textView.documentRange.intRange + let folds = foldCache.folds(in: documentRange) + guard let minimumDepth = folds.map(\.depth).min() else { return } + + let targets = isCollapsed ? folds.filter { $0.depth == minimumDepth } : folds.reversed() + for fold in targets { + setCollapsed(isCollapsed, for: fold) + } + } + + func isCollapsed(_ fold: FoldRange) -> Bool { + foldCache.isCollapsed(fold.id) ?? fold.isCollapsed + } + + func collapsedFoldRanges() -> [Range] { + guard let controller else { return [] } + return foldCache + .folds(in: controller.textView.documentRange.intRange) + .filter(\.isCollapsed) + .map(\.range) + } + + /// Queues ranges to collapse once the fold cache next reports folds for them. + func restoreCollapsedFolds(_ ranges: [Range]) { + guard !ranges.isEmpty else { return } + pendingRestoreRanges = Set(ranges) + applyPendingRestoreIfPossible() + } + + private func applyPendingRestoreIfPossible() { + guard let pending = pendingRestoreRanges, let controller else { return } + let folds = foldCache.folds(in: controller.textView.documentRange.intRange) + guard !folds.isEmpty else { return } + + pendingRestoreRanges = nil + for fold in folds where pending.contains(fold.range) && !fold.isCollapsed { + setCollapsed(true, for: fold) + } + } + + // MARK: - Hover + + /// A collapsed fold the pointer is resting on, and where its placeholder is drawn. + struct PlaceholderHover: Equatable { + let fold: FoldRange + let hit: CollapsedFoldHit + } + + /// The fold the pointer is on, whichever of the two controls reports it. + /// + /// The gutter's chevron and the collapsed fold's placeholder are two views onto the same fold, and only one of + /// them can be under the pointer. Both report here instead of each keeping its own idea of what is hovered, so + /// pointing at either one lights up both. + private(set) var hoveredFold: FoldRange? + + private var gutterHover: FoldRange? + private var placeholderHover: PlaceholderHover? + + /// Reports the fold whose chevron the pointer is on, or `nil` when it is on none of them. + func setGutterHover(_ fold: FoldRange?) { + guard gutterHover?.id != fold?.id else { return } + gutterHover = fold + resolveHover() + } + + /// Reports the collapsed fold whose placeholder the pointer is on, or `nil` when it is on none of them. + /// + /// Scrolling moves a placeholder out from under a stationary pointer, so this is compared on the whole hit + /// rather than on the fold: a peek anchored to the old rect has to be told the anchor moved. + func setPlaceholderHover(_ hover: PlaceholderHover?) { + guard placeholderHover != hover else { return } + placeholderHover = hover + resolveHover() + controller?.foldHoverDidChange(hover?.hit) + } + + private func resolveHover() { + let resolved = placeholderHover?.fold ?? gutterHover + guard resolved?.id != hoveredFold?.id else { return } + hoveredFold = resolved + foldView?.needsDisplay = true + + guard let resolved else { + clearEmphasis() + return + } + emphasizeBracketsForFold(resolved) + } + + private func attachment(for fold: FoldRange) -> AnyTextAttachment? { + guard let layoutManager = controller?.textView?.layoutManager, + let firstLine = layoutManager.textLineForOffset(fold.range.lowerBound) else { return nil } + return layoutManager.attachments + .getAttachmentsStartingIn(NSRange(fold.range)) + .first { + $0.attachment is LineFoldPlaceholder && firstLine.range.contains($0.range.location) + } + } + + private func makePlaceholder(for fold: FoldRange, controller: TextViewController) -> LineFoldPlaceholder? { + guard let text = controller.textView.textStorage?.string as NSString?, + let layoutManager = controller.textView?.layoutManager else { return nil } + let startLine = layoutManager.textLineForOffset(fold.range.lowerBound)?.index ?? 0 + let endLine = layoutManager.textLineForOffset(max(fold.range.lowerBound, fold.range.upperBound - 1))?.index + ?? startLine + let summary = FoldPlaceholderSummary.summarize( + content: text, + foldRange: fold.range, + hiddenLineCount: max(0, endLine - startLine) + ) + + return LineFoldPlaceholder( + delegate: self, + fold: fold, + charWidth: controller.font.charWidth, + label: controller.foldProvider.foldPlaceholderLabel(for: summary), + font: controller.font + ) + } + /// Recomputes folds for the whole document. Used to catch up after the ribbon has been hidden and is shown again. func refresh() { textChangedStreamContinuation.yield() @@ -78,13 +277,6 @@ class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject { textChangedStreamContinuation.yield() } - /// Finds the deepest cached depth of the fold for a line number. - /// - Parameter lineNumber: The line number to query, zero-indexed. - /// - Returns: The deepest cached depth of the fold if it was found. - func getCachedDepthAt(lineNumber: Int) -> Int? { - return getCachedFoldAt(lineNumber: lineNumber)?.depth - } - /// Finds the deepest cached fold and depth of the fold for a line number. /// - Parameter lineNumber: The line number to query, zero-indexed. /// - Returns: The deepest cached fold and depth of the fold if it was found. @@ -104,7 +296,7 @@ class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject { return deepestFold } - func emphasizeBracketsForFold(_ fold: FoldRange) { + private func emphasizeBracketsForFold(_ fold: FoldRange) { clearEmphasis() // Find the text object, make sure there's available characters around the fold. @@ -131,7 +323,7 @@ class LineFoldModel: NSObject, NSTextStorageDelegate, ObservableObject { ) } - func clearEmphasis() { + private func clearEmphasis() { controller?.textView.emphasisManager?.removeEmphases(for: Self.emphasisId) } } @@ -156,8 +348,7 @@ extension LineFoldModel: LineFoldPlaceholderDelegate { } func placeholderDiscarded(fold: FoldRange) { - foldCache.toggleCollapse(forFold: fold) - foldView?.needsDisplay = true - textChangedStreamContinuation.yield() + setPlaceholderHover(nil) + commitCollapseChange(for: fold) } } diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift index b734be850..33999bea4 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Model/LineFoldStorage.swift @@ -78,6 +78,11 @@ struct LineFoldStorage: Sendable { store.storageUpdated(editedRange: editedRange, changeInLength: delta) } + /// The collapse state of a fold by id, or `nil` when the fold is no longer in the store. + func isCollapsed(_ id: FoldRange.FoldIdentifier) -> Bool? { + foldRanges[id]?.isCollapsed + } + mutating func toggleCollapse(forFold fold: FoldRange) { guard var existingRange = foldRanges[fold.id] else { return } existingRange.isCollapsed.toggle() diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Placeholder/FoldPlaceholderSummary.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Placeholder/FoldPlaceholderSummary.swift new file mode 100644 index 000000000..da4a8e951 --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/Placeholder/FoldPlaceholderSummary.swift @@ -0,0 +1,60 @@ +// +// FoldPlaceholderSummary.swift +// CodeEditSourceEditor +// + +import Foundation + +/// Describes the content hidden by a collapsed fold, so a placeholder can preview it. +public struct FoldPlaceholderSummary: Equatable, Sendable { + /// The number of UTF-16 units read from the folded region to build the preview. + public static let scanWindow = 200 + + /// The longest preview string produced, before any provider adds a line count to it. + public static let maxPreviewLength = 40 + + public let previewText: String + public let hiddenLineCount: Int + + public init(previewText: String, hiddenLineCount: Int) { + self.previewText = previewText + self.hiddenLineCount = hiddenLineCount + } + + /// Builds a summary for a folded region. + /// + /// Only ``scanWindow`` units are read, so a fold hiding a multi-megabyte line costs the same as one hiding a short + /// one. + public static func summarize(content: NSString, foldRange: Range, hiddenLineCount: Int) -> Self { + let start = max(0, min(foldRange.lowerBound, content.length)) + let end = max(start, min(foldRange.upperBound, content.length)) + let windowLength = min(end - start, scanWindow) + guard windowLength > 0 else { + return Self(previewText: "", hiddenLineCount: max(0, hiddenLineCount)) + } + + let window = content.substring(with: NSRange(location: start, length: windowLength)) + let collapsed = window + .components(separatedBy: .whitespacesAndNewlines) + .filter { !$0.isEmpty } + .joined(separator: " ") + + return Self( + previewText: truncate(collapsed, to: maxPreviewLength), + hiddenLineCount: max(0, hiddenLineCount) + ) + } + + private static func truncate(_ text: String, to limit: Int) -> String { + var count = 0 + var endIndex = text.startIndex + for index in text.indices { + if count == limit { + return String(text[text.startIndex.. 0 else { return charWidth * 5 } + return min(labelWidth + (charWidth * 3), charWidth * Self.maxWidthInCharacters) } func draw(in context: CGContext, rect: NSRect) { - context.saveGState() - guard let delegate else { return } - let size = charWidth / 2.5 - let centerY = rect.midY - (size / 2.0) - - if isSelected { - context.setFillColor(delegate.placeholderSelectedColor().safeCGColor) - } else { - context.setFillColor(delegate.placeholderBackgroundColor().safeCGColor) - } + context.saveGState() + defer { context.restoreGState() } + let background = isSelected ? delegate.placeholderSelectedColor() : delegate.placeholderBackgroundColor() + context.setFillColor(background.safeCGColor) context.addPath( NSBezierPath( - rect: rect.transform(x: charWidth, y: 2.0, width: -charWidth * 2, height: -4.0), + rect: rect.transform(x: charWidth / 2, y: 2.0, width: -charWidth, height: -4.0), roundedCorners: .all, cornerRadius: rect.height / 2 ).cgPathFallback ) context.fillPath() - if isSelected { - context.setFillColor(delegate.placeholderSelectedTextColor().safeCGColor) - } else { - context.setFillColor(delegate.placeholderTextColor().safeCGColor) + let foreground = isSelected ? delegate.placeholderSelectedTextColor() : delegate.placeholderTextColor() + guard !label.isEmpty else { + drawEllipsis(in: context, rect: rect, color: foreground) + return } + + drawLabel(in: context, rect: rect, color: foreground) + } + + private func drawLabel(in context: CGContext, rect: NSRect, color: NSColor) { + let text = NSAttributedString(string: label, attributes: [.font: font, .foregroundColor: color]) + let available = rect.width - (charWidth * 2) + guard available > 0 else { return } + + let line = CTLineCreateWithAttributedString(text) + var ascent: CGFloat = 0 + var descent: CGFloat = 0 + CTLineGetTypographicBounds(line, &ascent, &descent, nil) + + context.saveGState() + defer { context.restoreGState() } + + context.clip(to: rect.transform(x: charWidth, width: -charWidth * 2)) + context.textMatrix = CGAffineTransform(scaleX: 1, y: -1) + context.textPosition = CGPoint( + x: rect.minX + charWidth, + y: rect.midY + ((ascent - descent) / 2) + ) + CTLineDraw(line, context) + } + + private func drawEllipsis(in context: CGContext, rect: NSRect, color: NSColor) { + let size = charWidth / 2.5 + let centerY = rect.midY - (size / 2.0) + + context.setFillColor(color.safeCGColor) context.addEllipse( in: CGRect(x: rect.minX + (charWidth * 2) - size, y: centerY, width: size, height: size) ) @@ -76,10 +114,13 @@ class LineFoldPlaceholder: TextAttachment { in: CGRect(x: rect.maxX - (charWidth * 2), y: centerY, width: size, height: size) ) context.fillPath() - - context.restoreGState() } + /// A placeholder stands in for code the reader wants back, so one click brings it back. The attachment manager + /// removes the placeholder itself, and the delegate records the change; the gutter's chevron removes it through + /// the layout manager instead. Both routes end at the same commit, so the two can never disagree. + var activatesOnSingleClick: Bool { true } + func attachmentAction() -> TextAttachmentAction { delegate?.placeholderDiscarded(fold: fold) return .discard diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldPlaceholderTracker.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldPlaceholderTracker.swift new file mode 100644 index 000000000..26dc91076 --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldPlaceholderTracker.swift @@ -0,0 +1,115 @@ +// +// LineFoldPlaceholderTracker.swift +// CodeEditSourceEditor +// + +import AppKit +import CodeEditTextView + +/// Reports which collapsed fold's placeholder the pointer is resting on. +/// +/// A placeholder is drawn by the layout manager rather than being a view, so there is nothing to hang a tracking area +/// on. The tracking area goes on the text view itself with this object as its owner, which is what an owner is for: +/// `NSTrackingArea` takes any object, so reporting pointer movement needs no view of its own and no hit testing to +/// step around. `.inVisibleRect` leaves AppKit to keep the area matched to what is on screen, so nothing has to +/// recompute it when the document grows or the editor is resized. +/// +/// Scrolling moves placeholders under a pointer that never moved, and a stationary pointer generates no events, so the +/// editor's own scroll notification re-resolves from the window's current pointer location. +@MainActor +final class LineFoldPlaceholderTracker: NSResponder { + private weak var model: LineFoldModel? + private weak var trackedView: TextView? + private var trackingArea: NSTrackingArea? + private var scrollObserver: NSObjectProtocol? + + init(model: LineFoldModel) { + self.model = model + super.init() + install() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let scrollObserver { + NotificationCenter.default.removeObserver(scrollObserver) + } + } + + private func install() { + guard let controller = model?.controller, let textView = controller.textView else { return } + + let area = NSTrackingArea( + rect: .zero, + options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + ) + textView.addTrackingArea(area) + trackingArea = area + trackedView = textView + + scrollObserver = NotificationCenter.default.addObserver( + forName: TextViewController.scrollPositionDidUpdateNotification, + object: controller, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.resolveFromCurrentPointer() + } + } + } + + /// Removes the tracking area. The area's owner is this object, so it must not outlive it. + func destroy() { + if let trackingArea { + trackedView?.removeTrackingArea(trackingArea) + } + trackingArea = nil + trackedView = nil + if let scrollObserver { + NotificationCenter.default.removeObserver(scrollObserver) + } + scrollObserver = nil + model?.setPlaceholderHover(nil) + } + + override func mouseEntered(with event: NSEvent) { + resolve(atWindowPoint: event.locationInWindow) + } + + override func mouseMoved(with event: NSEvent) { + resolve(atWindowPoint: event.locationInWindow) + } + + override func mouseExited(with event: NSEvent) { + model?.setPlaceholderHover(nil) + } + + private func resolveFromCurrentPointer() { + guard let window = trackedView?.window else { return } + resolve(atWindowPoint: window.mouseLocationOutsideOfEventStream) + } + + private func resolve(atWindowPoint windowPoint: CGPoint) { + guard let controller = model?.controller, let textView = controller.textView else { return } + + // Nothing is folded in most documents most of the time, and resolving a point to an offset is a layout + // query. Asking whether there is anything to hit first keeps moving the pointer over an ordinary document + // free. + guard !textView.layoutManager.attachments.isEmpty else { + model?.setPlaceholderHover(nil) + return + } + + let point = textView.convert(windowPoint, from: nil) + guard textView.visibleRect.contains(point) else { + model?.setPlaceholderHover(nil) + return + } + model?.setPlaceholderHover(controller.placeholderHover(at: point)) + } +} diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Accessibility.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Accessibility.swift new file mode 100644 index 000000000..d21a942eb --- /dev/null +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Accessibility.swift @@ -0,0 +1,98 @@ +// +// LineFoldRibbonView+Accessibility.swift +// CodeEditSourceEditor +// + +import AppKit +import CodeEditTextView + +/// One fold's disclosure chevron, as assistive technology sees it. +/// +/// The chevrons are drawn rather than being views, which is what keeps scrolling a long document cheap, but a drawn +/// control is not a control as far as VoiceOver is concerned. This reports the same thing the chevron shows: a +/// disclosure triangle whose value is whether the block is collapsed, and pressing it toggles the fold through the +/// same call the pointer does. +final class FoldDisclosureElement: NSAccessibilityElement { + private weak var model: LineFoldModel? + private let fold: FoldRange + private let lineNumber: Int + + init(fold: FoldRange, lineNumber: Int, model: LineFoldModel?) { + self.fold = fold + self.lineNumber = lineNumber + self.model = model + super.init() + } + + override func accessibilityRole() -> NSAccessibility.Role? { + .disclosureTriangle + } + + override func accessibilityLabel() -> String? { + "Fold starting on line \(lineNumber + 1)" + } + + override func accessibilityValue() -> Any? { + MainActor.assumeIsolated { + (model?.isCollapsed(fold) ?? false) ? 1 : 0 + } + } + + override func isAccessibilityEnabled() -> Bool { + true + } + + override func accessibilityPerformPress() -> Bool { + MainActor.assumeIsolated { + guard let model else { return false } + model.setCollapsed(!model.isCollapsed(fold), for: fold) + return true + } + } +} + +extension LineFoldRibbonView { + override func isAccessibilityElement() -> Bool { + true + } + + override func accessibilityRole() -> NSAccessibility.Role? { + .group + } + + override func accessibilityLabel() -> String? { + "Code folding" + } + + /// One element per chevron on screen. + /// + /// Built on demand rather than kept in step with the folds, because the fold set changes on every edit and + /// assistive technology reads this far less often than the document changes. + override func accessibilityChildren() -> [Any]? { + guard let model, let layoutManager = model.controller?.textView.layoutManager, + let range = documentRange(covering: visibleRect, layoutManager: layoutManager) else { return [] } + + let folds = foldsByStartLine(in: range, layoutManager: layoutManager) + + return folds + .sorted { $0.key < $1.key } + .compactMap { lineNumber, fold -> FoldDisclosureElement? in + guard let line = layoutManager.textLineForIndex(lineNumber) else { return nil } + let element = FoldDisclosureElement(fold: fold, lineNumber: lineNumber, model: model) + element.setAccessibilityParent(self) + element.setAccessibilityFrame( + screenRect(CGRect(x: 0, y: line.yPos, width: bounds.width, height: line.height)) + ) + return element + } + } + + /// A rect in this view's coordinates, in screen coordinates. + /// + /// Accessibility frames are always screen rects. Converting through the window rather than setting a frame in + /// parent space keeps this correct in a flipped view, which this one is. + private func screenRect(_ rect: CGRect) -> CGRect { + guard let window else { return .zero } + return window.convertToScreen(convert(rect, to: nil)) + } +} diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Draw.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Draw.swift index addf06439..a4a10203e 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Draw.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/LineFolding/View/LineFoldRibbonView+Draw.swift @@ -9,327 +9,98 @@ import AppKit import CodeEditTextView extension LineFoldRibbonView { - struct DrawingFoldInfo { - let fold: FoldRange - let startLine: TextLineStorage.TextLinePosition - let endLine: TextLineStorage.TextLinePosition - } - - // MARK: - Draw - override func draw(_ dirtyRect: NSRect) { guard let context = NSGraphicsContext.current?.cgContext, - let layoutManager = model?.controller?.textView.layoutManager, - // Find the visible lines in the rect AppKit is asking us to draw. - let rangeStart = layoutManager.textLineForPosition(dirtyRect.minY), - let rangeEnd = layoutManager.textLineForPosition(dirtyRect.maxY) else { + let model, + let layoutManager = model.controller?.textView.layoutManager, + let range = documentRange(covering: dirtyRect, layoutManager: layoutManager) else { return } context.saveGState() + defer { context.restoreGState() } context.clip(to: dirtyRect) - // Only draw folds in the requested dirty rect - let folds = getDrawingFolds( - forTextRange: rangeStart.range.location.. 1. In this case, we still need to draw those - /// layers of color to create the illusion that those folds are continuous under the nested folds. To achieve this, - /// we create 'fake' folds that span more than the queried text range. When returned for drawing, the drawing - /// methods will draw those extra folds normally. - /// - /// - Parameters: - /// - textRange: The range of characters in text to create drawing fold info for. - /// - layoutManager: A layout manager to query for line layout information. - /// - Returns: A list of folds to draw for the given text range. - private func getDrawingFolds( - forTextRange textRange: Range, - layoutManager: TextLayoutManager - ) -> [DrawingFoldInfo] { - var folds = model?.getFolds(in: textRange) ?? [] - - // Add in some fake depths, we can draw these underneath the rest of the folds to make it look like it's - // continuous - if let minimumDepth = folds.min(by: { $0.depth < $1.depth })?.depth { - for depth in (1..