From c0a9783190af26b821c523b5a872ff789ebdef75 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 20:41:28 +0700 Subject: [PATCH 1/2] fix(editor): keep line numbers and the find panel inside the editor On macOS 14 and later nothing below the editor's root view clipped, so the document-tall gutter painted line numbers of rows scrolled out of view over the view above it, and the find panel slid over it too. Clip the find view controller's view, the editor's outermost view. --- .../Find/FindViewController.swift | 15 +- .../TextViewControllerClippingTests.swift | 153 ++++++++++++++++++ 2 files changed, 161 insertions(+), 7 deletions(-) create mode 100644 apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerClippingTests.swift diff --git a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift index d38d718ee8..1e303bee1c 100644 --- a/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift +++ b/apple/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift @@ -54,13 +54,14 @@ final class FindViewController: NSViewController { view = PlainControllerView.make() } - // Set up the `childView` as a subview of our view. Constrained to all edges, except the top is constrained to - // the find panel's bottom - // The find panel is constrained to the top of the view. - // The find panel's top anchor when hidden, is equal to it's negated height hiding it above the view's contents. - // When visible, it's set to 0. - - view.clipsToBounds = false + /// The editor's outermost view, so the one place that keeps it inside its own frame. `NSView.clipsToBounds` + /// defaults to NO from macOS 14 (NSView.h), and nothing below this view clips on its own: AppKit moves the + /// gutter, a floating subview, into a `_NSScrollViewFloatingSubviewsContainerView` beside the clip view, and + /// neither that container nor `NSScrollView` clips. The gutter is document-tall, so the line numbers of rows + /// scrolled out of view painted over whatever the host stacked above the editor, and the find panel sliding + /// in from above and back out did the same. Clipping here also bounds the gutter's `visibleRect`, which + /// scopes its hover tracking and the ribbons' accessibility elements. + view.clipsToBounds = true view.addSubview(childView) // Ordered above the content so the panel wins hit-testing as well as drawing. // `zPosition` alone only affects drawing, which left clicks falling through to the diff --git a/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerClippingTests.swift b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerClippingTests.swift new file mode 100644 index 0000000000..ac8d8a6b29 --- /dev/null +++ b/apple/Packages/TableProEditor/Tests/TableProEditorKitTests/Controller/TextViewControllerClippingTests.swift @@ -0,0 +1,153 @@ +// +// TextViewControllerClippingTests.swift +// TableProEditorKit +// + +import AppKit +@testable import TableProEditorKit +import TableProTextEngine +import Testing + +/// The editor draws nothing outside its own frame, whatever the host stacks above it. +/// +/// The editor is the real controller configured the way the read-only code views configure it, placed under an +/// opaque band in a window and scrolled well down. The band stands for the toolbar of the EXPLAIN result pane, where +/// the line numbers of rows scrolled out of view used to paint over the Diagram, Tree and Raw picker. +@MainActor +struct TextViewControllerClippingTests { + private static let editorFrame = NSRect(x: 0, y: 0, width: 500, height: 340) + private static let bandHeight: CGFloat = 60 + + private final class Band: NSView { + override func draw(_ dirtyRect: NSRect) { + NSColor(srgbRed: 1, green: 0, blue: 0, alpha: 1).setFill() + bounds.fill() + } + } + + private let window: NSWindow + private let root: NSView + private let controller: TextViewController + + init() { + let text = (1...300).map { "SELECT \($0) FROM orders WHERE id = \($0);" }.joined(separator: "\n") + controller = TextViewController( + string: text, + language: .default, + configuration: SourceEditorConfiguration( + appearance: .init( + theme: Mock.theme(), + font: .monospacedSystemFont(ofSize: 13, weight: .regular), + wrapLines: false + ), + behavior: .init(isEditable: false), + layout: .init(contentInsets: NSEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)), + peripherals: .init( + showGutter: true, + showLineNumbers: true, + showFoldingRibbon: true, + showStatementRunControls: true, + gutterFitsContent: true + ) + ), + cursorPositions: [], + highlightProviders: [] + ) + controller.loadView() + + let size = NSSize(width: Self.editorFrame.width, height: Self.editorFrame.height + Self.bandHeight) + window = NSWindow( + contentRect: NSRect(origin: NSPoint(x: 200, y: 200), size: size), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + root = NSView(frame: NSRect(origin: .zero, size: size)) + root.wantsLayer = true + window.contentView = root + root.addSubview(Band(frame: NSRect( + x: 0, + y: Self.editorFrame.maxY, + width: size.width, + height: Self.bandHeight + ))) + controller.view.translatesAutoresizingMaskIntoConstraints = true + controller.view.autoresizingMask = [] + controller.view.frame = Self.editorFrame + root.addSubview(controller.view) + + root.layoutSubtreeIfNeeded() + controller.textView.updateFrameIfNeeded() + controller.textView.layoutManager.layoutLines() + controller.gutterView.updateWidthIfNeeded() + let clip = controller.scrollView.contentView + clip.scroll(to: NSPoint(x: clip.bounds.minX, y: 600)) + controller.scrollView.reflectScrolledClipView(clip) + root.layoutSubtreeIfNeeded() + } + + private var editorInWindow: NSRect { + controller.view.convert(controller.view.bounds, to: nil) + } + + /// Where `view` may draw, in window coordinates: what AppKit hands its `draw(_:)` and scopes its tracking to. + private func visibleRectInWindow(_ view: NSView) -> NSRect { + view.convert(view.visibleRect, to: nil) + } + + private func contains(_ outer: NSRect, _ inner: NSRect) -> Bool { + inner.isEmpty || outer.insetBy(dx: -0.5, dy: -0.5).contains(inner) + } + + @Test("The gutter's line numbers paint nothing over the view above the editor") + func gutterPaintsNothingAboveTheEditor() throws { + controller.gutterView.needsDisplay = true + let rep = try #require(root.bitmapImageRepForCachingDisplay(in: root.bounds)) + root.cacheDisplay(in: root.bounds, to: rep) + let scale = CGFloat(rep.pixelsWide) / root.bounds.width + + /// The band measured where the gutter cannot reach, rather than the color it fills with: the cached drawing + /// lands in a color space that shifts the fill. + let untouched = try #require(rep.colorAt(x: Int((root.bounds.maxX - 20) * scale), y: Int(10 * scale))) + let gutterWidth = controller.gutterView.frame.maxX + #expect(gutterWidth > 0) + var inked = 0 + for x in stride(from: CGFloat(1), to: gutterWidth, by: 1) { + for y in stride(from: CGFloat(1), to: Self.bandHeight - 1, by: 1) { + let color = rep.colorAt(x: Int(x * scale), y: Int(y * scale)) + if !Self.isSame(color, untouched) { inked += 1 } + } + } + #expect(inked == 0, "the gutter painted \(inked) points above the editor") + } + + @Test("The gutter and its ribbons are asked to draw only inside the editor") + func gutterDrawsOnlyInsideTheEditor() throws { + let gutter = try #require(controller.gutterView) + #expect(gutter.frame.height > Self.editorFrame.height, "the gutter is document-tall") + #expect(contains(editorInWindow, visibleRectInWindow(gutter))) + #expect(contains(editorInWindow, visibleRectInWindow(gutter.foldingRibbon))) + #expect(contains(editorInWindow, visibleRectInWindow(gutter.statementRunRibbon))) + } + + @Test("The find panel sliding in from above shows only inside the editor") + func findPanelSlidesInsideTheEditor() throws { + let find = try #require(controller.findViewController) + find.findPanel.isHidden = false + find.findPanelVerticalConstraint.constant = find.resolvedTopPadding - find.viewModel.panelHeight / 2 + find.view.layoutSubtreeIfNeeded() + + let panelInWindow = find.findPanel.convert(find.findPanel.bounds, to: nil) + #expect(panelInWindow.maxY > editorInWindow.maxY, "the panel starts its slide above the editor") + #expect(contains(editorInWindow, visibleRectInWindow(find.findPanel))) + } + + private static func isSame(_ lhs: NSColor?, _ rhs: NSColor) -> Bool { + guard let lhs = lhs?.usingColorSpace(.sRGB), let rhs = rhs.usingColorSpace(.sRGB) else { return false } + let tolerance: CGFloat = 0.02 + return abs(lhs.redComponent - rhs.redComponent) <= tolerance + && abs(lhs.greenComponent - rhs.greenComponent) <= tolerance + && abs(lhs.blueComponent - rhs.blueComponent) <= tolerance + } +} From f4c1b36dc2b41fc833f09ce18687d5c8448174cf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 19 Oct 2026 20:42:33 +0700 Subject: [PATCH 2/2] docs(changelog): add the fragment for the editor clip fix --- apple/changelog.d/3591.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3591.fixed.md diff --git a/apple/changelog.d/3591.fixed.md b/apple/changelog.d/3591.fixed.md new file mode 100644 index 0000000000..837b77c600 --- /dev/null +++ b/apple/changelog.d/3591.fixed.md @@ -0,0 +1 @@ +Line numbers in a read-only code view, such as EXPLAIN Raw, no longer draw over the toolbar above it.