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
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
26 changes: 15 additions & 11 deletions TablePro/Views/Main/EditorTabStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -129,20 +126,26 @@ 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)
/// Clipped to the same shape the track is drawn as, so a tab scrolled under a rounded end
/// 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] {
Expand All @@ -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),
Expand Down
12 changes: 12 additions & 0 deletions TablePro/Views/Main/EditorTabStripInteraction.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
34 changes: 34 additions & 0 deletions TableProTests/Views/Main/EditorTabStripInteractionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading