From 6c41f9f2805c8a14000a8b7033019d72ace3dddd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 15 Sep 2026 23:24:33 +0700 Subject: [PATCH 1/2] fix(editor): build the view AppKit builds for a nib-less controller on macOS 13 --- .../Controller/PlainControllerView.swift | 23 +++++++++++++++++++ .../TextViewController+Lifecycle.swift | 13 +++++++---- .../Find/FindViewController.swift | 10 +++++--- .../TextLine/LineFragmentView.swift | 7 ++++++ .../TextView/TextView.swift | 3 +++ 5 files changed, 48 insertions(+), 8 deletions(-) create mode 100644 Packages/TableProEditor/Sources/TableProEditorKit/Controller/PlainControllerView.swift diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Controller/PlainControllerView.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Controller/PlainControllerView.swift new file mode 100644 index 000000000..2ba39dfd0 --- /dev/null +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Controller/PlainControllerView.swift @@ -0,0 +1,23 @@ +// +// PlainControllerView.swift +// TableProEditorKit +// + +import AppKit + +internal enum PlainControllerView { + /// What `NSViewController.loadView()` builds for a controller that has no nib. macOS 13 raises + /// there instead of building it, because it looks for a nib named after the class first, so the + /// two controllers in this module build it by hand on 13 and keep `super` on 14. + /// + /// A bare `NSView()` is not the same view. Measured against the default implementation: the + /// frame is 500pt square rather than zero, and the autoresizing mask is `[.width, .height]` + /// rather than empty. The mask is the load-bearing half. With `translatesAutoresizingMask` + /// left on, an empty mask pins the view at the size it was born with, so an editor built this + /// way stays at zero size and draws nothing. + internal static func make() -> NSView { + let view = NSView(frame: NSRect(x: 0, y: 0, width: 500, height: 500)) + view.autoresizingMask = [.width, .height] + return view + } +} diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift index 7713d8686..110d9c4a6 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Controller/TextViewController+Lifecycle.swift @@ -25,11 +25,14 @@ extension TextViewController { textCoordinators.forEach { $0.val?.controllerDidDisappear(controller: self) } } - override public func loadView() { - /// Not `super.loadView()`. With a nil `nibName`, macOS 13 looks for a nib named after the - /// class and raises when there is none; macOS 14 quietly makes an empty view instead. - /// This controller has no nib on either, so it makes the view itself. - view = NSView() + override public func loadView() { // swiftlint:disable:this prohibited_super_call + /// macOS 13 raises out of `super.loadView()` for a controller with no nib. See + /// `PlainControllerView`, which is what `super` builds on 14. + if #available(macOS 14.0, *) { + super.loadView() + } else { + view = PlainControllerView.make() + } scrollView = SourceEditorScrollView() scrollView.documentView = textView diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift index 19855ead2..d38d718ee 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Find/FindViewController.swift @@ -46,9 +46,13 @@ final class FindViewController: NSViewController { fatalError("init(coder:) has not been implemented") } - override func loadView() { - /// See `TextViewController.loadView()`: `super` looks for a nib on macOS 13 and raises. - view = NSView() + override func loadView() { // swiftlint:disable:this prohibited_super_call + /// See `PlainControllerView`: `super` looks for a nib on macOS 13 and raises. + if #available(macOS 14.0, *) { + super.loadView() + } else { + 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 diff --git a/Packages/TableProEditor/Sources/TableProTextEngine/TextLine/LineFragmentView.swift b/Packages/TableProEditor/Sources/TableProTextEngine/TextLine/LineFragmentView.swift index 064521300..b6940d0d8 100644 --- a/Packages/TableProEditor/Sources/TableProTextEngine/TextLine/LineFragmentView.swift +++ b/Packages/TableProEditor/Sources/TableProTextEngine/TextLine/LineFragmentView.swift @@ -27,6 +27,13 @@ open class LineFragmentView: NSView { override public init(frame frameRect: NSRect) { super.init(frame: frameRect) + /// `NSView.clipsToBounds` defaults to YES before macOS 14 and NO from 14 on (NSView.h), and + /// the whole renderer is written against the later default: a fragment's ink reaches past + /// its own width, and `LineFragmentRenderer` reads the context's clip to decide how much of + /// a line to draw. Left at the earlier default, a fragment is clipped to itself and a + /// fragment whose frame is briefly wrong draws nothing at all rather than overflowing. + /// `API_AVAILABLE(macos(10.9))` on the property means no availability check can see this. + clipsToBounds = false } public required init?(coder: NSCoder) { diff --git a/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView.swift b/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView.swift index f441ccb3c..adb64b68d 100644 --- a/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView.swift +++ b/Packages/TableProEditor/Sources/TableProTextEngine/TextView/TextView.swift @@ -341,6 +341,9 @@ open class TextView: NSView, NSTextContent { } wantsLayer = true + /// See `LineFragmentView`: the pre-macOS-14 default clips a view to its own bounds, and + /// every line fragment, selection and cursor is a subview of this one. + clipsToBounds = false postsFrameChangedNotifications = true postsBoundsChangedNotifications = true autoresizingMask = [.width, .height] From 3d591bc58c484323aa656afd91f1ca6c75b8c090 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 15 Sep 2026 23:24:33 +0700 Subject: [PATCH 2/2] fix(tabs): seed the tab strip's run where the strip is built --- ...plitViewController+TabStripAccessory.swift | 7 ++++ TablePro/Views/Main/EditorTabStrip.swift | 26 ++++++++------ .../Main/EditorTabStripInteraction.swift | 12 +++++++ .../Main/EditorTabStripInteractionTests.swift | 34 +++++++++++++++++++ 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift index b512456c1..90aebf860 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift @@ -174,5 +174,12 @@ internal extension MainSplitViewController { ) } ) + /// Seeded here, where the strip is built, rather than from one of its view modifiers. See + /// `EditorTabStripInteraction.adopt(tabIds:overflow:)`; the strip's own `onChange` carries + /// every later change. + interaction.adopt( + tabIds: manager.tabs.map(\.id), + overflow: AppSettingsManager.shared.tabs.overflow + ) } } diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index 1828e753f..1fec6b1fb 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -78,11 +78,8 @@ internal struct EditorTabStrip: View { .padding(.horizontal, EditorTabStripLayout.stripInset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) /// A closed tab leaves its id behind, and the tab that slides into its place would - /// otherwise light up under a pointer that never moved onto it. - .onAppear { - interaction.dropClosedTabs(keeping: tabManager.tabs.map(\.id)) - interaction.overflow = settings.tabs.overflow - } + /// otherwise light up under a pointer that never moved onto it. The initial list is seeded + /// where the strip is built, so these two carry changes only. .onChange(of: tabManager.tabs.map(\.id)) { ids in interaction.dropClosedTabs(keeping: ids) } @@ -129,9 +126,15 @@ internal struct EditorTabStrip: View { /// press owns the wheel and the autoscroll too, so one object decides where a tab is and the /// drawing follows it rather than the two agreeing by construction. private var track: some View { - ZStack(alignment: .topLeading) { - ForEach(Array(displayedTabs.enumerated()), id: \.element.id) { index, tab in - item(for: tab, at: index, in: displayedTabs, label: labels[tab.id]) + /// The run and the list are read once, together, and the placement is handed to each item + /// rather than looked up again inside the `ForEach`, whose closures SwiftUI evaluates + /// lazily. One paint is then measured against one run: a tab drawn from a list the run was + /// not built for has no rectangle, and is drawn nowhere. + let run = interaction.run + let tabs = displayedTabs + return ZStack(alignment: .topLeading) { + ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in + item(for: tab, at: index, in: tabs, placement: run.placement(at: index), label: labels[tab.id]) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -139,10 +142,10 @@ internal struct EditorTabStrip: View { /// is cut by that curve instead of squaring it off. A capsule only stays right for one /// row: its radius is half the height, so a wrapped track would curve away most of the /// first row's close target while the pointer still hit-tests the whole rectangle. - .clipShape(EditorTabStripLayout.trackShape(forRowCount: interaction.run.rowCount)) + .clipShape(EditorTabStripLayout.trackShape(forRowCount: run.rowCount)) .padding(EditorTabStripLayout.trackPadding) .frame(height: trackHeight) - .trackSurface(rowCount: interaction.run.rowCount) + .trackSurface(rowCount: run.rowCount) } private var displayedTabs: [QueryTab] { @@ -159,9 +162,10 @@ internal struct EditorTabStrip: View { for tab: QueryTab, at index: Int, in tabs: [QueryTab], + placement: EditorTabPlacement?, label: EditorTabLabelResolver.Label? ) -> some View { - if let placement = interaction.run.placement(at: index) { + if let placement { EditorTabStripItem( tab: tab, label: label ?? EditorTabLabelResolver.Label(text: tab.title, description: tab.title), diff --git a/TablePro/Views/Main/EditorTabStripInteraction.swift b/TablePro/Views/Main/EditorTabStripInteraction.swift index 176eefd5e..553901c48 100644 --- a/TablePro/Views/Main/EditorTabStripInteraction.swift +++ b/TablePro/Views/Main/EditorTabStripInteraction.swift @@ -160,6 +160,18 @@ internal final class EditorTabStripInteraction: ObservableObject { tearingOffTabId = nil } + /// Takes the tab list and the overflow style from the model that owns them. + /// + /// The run `EditorTabInteractionView.layout()` measures is built from `tabIds`, so the list has + /// to arrive from whoever builds the strip rather than from one of its view modifiers: a band + /// is installed hidden and has no appearance to wait for, and a run measured against a list + /// that is still empty divides the whole track among the tabs it knew about, leaving the rest + /// without a placement. A tab with no placement is drawn nowhere. + internal func adopt(tabIds ids: [UUID], overflow style: EditorTabStripOverflow) { + overflow = style + dropClosedTabs(keeping: ids) + } + /// A tab that closed under the pointer leaves both orders, and the drag ends outright when the /// tab being dragged is the one that went. internal func dropClosedTabs(keeping ids: [UUID]) { diff --git a/TableProTests/Views/Main/EditorTabStripInteractionTests.swift b/TableProTests/Views/Main/EditorTabStripInteractionTests.swift index 98d328344..94e99f710 100644 --- a/TableProTests/Views/Main/EditorTabStripInteractionTests.swift +++ b/TableProTests/Views/Main/EditorTabStripInteractionTests.swift @@ -49,6 +49,40 @@ struct EditorTabStripInteractionTests { return (interaction, ids, recorder) } + /// `EditorTabStrip` draws a tab only where the run has a placement for its index, so a run + /// measured against a shorter list than the strip draws leaves the last tabs nowhere while the + /// rest share the whole track between them. The list used to arrive from the view's own + /// lifecycle, after the first layout pass had already measured a run for no tabs at all. + @Test("Adopting the model's tabs completes the run measured before any tab was known") + func adoptingAfterAnEmptyMeasurementCompletesTheRun() { + let ids = (0 ..< 5).map { _ in UUID() } + let interaction = EditorTabStripInteraction() + + interaction.updateRun(trackWidth: Self.trackWidth, count: 0) + #expect(interaction.run.placements.isEmpty) + + interaction.adopt(tabIds: ids, overflow: .scroll) + + #expect(interaction.displayedIds == ids) + #expect(interaction.run.placements.count == ids.count) + #expect(ids.indices.allSatisfy { interaction.run.placement(at: $0) != nil }) + } + + /// The style has to be in place for the same measurement, or a user who wraps their tabs gets + /// one paint of the scrolling geometry and a band sized for a row count the run never had. + @Test("Adopting carries the overflow style into the same run") + func adoptingCarriesTheOverflowStyle() { + let ids = (0 ..< 12).map { _ in UUID() } + let interaction = EditorTabStripInteraction() + + interaction.updateRun(trackWidth: Self.trackWidth, count: 0) + interaction.adopt(tabIds: ids, overflow: .rows) + + #expect(interaction.overflow == .rows) + #expect(interaction.run.rowCount > 1) + #expect(interaction.run.contentSize.width <= Self.trackWidth) + } + @Test("A reorder draws from its own order and leaves the manager alone until release") func reorderIsNotCommittedUntilRelease() { let (interaction, ids, recorder) = makeInteraction(tabCount: 4)