diff --git a/CHANGELOG.md b/CHANGELOG.md index b58c4a57e..689b5e709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,17 +13,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Per-connection MongoDB shell state, so a variable or function survives from one statement to the next. - Cursor method autocomplete after `find()` and `aggregate()`. - Copy To and Duplicate Database in the sidebar and the Database menu, carrying structure, data or both to any connection. (#2487) +- Tab rows in Settings > General > Tabs, wrapping the strip instead of scrolling it. (#2438) +- Autoscrolling while dragging a tab, so a tab can be moved past the run currently on screen. (#2438) ### Changed - MongoDB statements split as JavaScript rather than at every semicolon. - MongoDB editor diagnostics report JavaScript syntax errors rather than unsupported method names. +- Editor tab presses handled by AppKit rather than SwiftUI gestures. (#2438) - Connection-first labels with the database or schema on a second line in the connections strip. (#2550) ### Fixed - Parse error on any MongoDB filter written in shell syntax, such as `db.orders.find({status: 1})`. - MongoDB `.sort()` and `.projection()` silently ignored when written with unquoted keys. +- Tab drag doing nothing, about one drag in seven. (#2438) +- Tab drag released on a neighbour's exact centre leaving the order unchanged. (#2438) - Compare & Sync unable to drop an overloaded PostgreSQL routine, or any trigger. - PostgreSQL sequence DDL naming the schema it was read from, in SQL export and the structure editor. diff --git a/TablePro/Core/Services/Infrastructure/EditorTabReorder.swift b/TablePro/Core/Services/Infrastructure/EditorTabReorder.swift index 0611442ec..ef4fcfbbc 100644 --- a/TablePro/Core/Services/Infrastructure/EditorTabReorder.swift +++ b/TablePro/Core/Services/Infrastructure/EditorTabReorder.swift @@ -90,6 +90,15 @@ internal enum EditorTabReorderResolver { /// 2, whose midpoint has not been crossed, while tab 1's midpoint at 150 has been. Answering /// for the tab under the pointer alone returns nothing there, and a quick drag then commits a /// shorter move than the user made, or none at all. + /// How far past a midpoint counts as having crossed it. + /// + /// Releasing on a neighbour's exact centre is the most ordinary one-place drag there is, and it + /// lands the pointer on the midpoint itself. Comparing for equality there made the move a coin + /// flip: the location arrives from a view geometry conversion, so it is 609.99998 as often as + /// it is 610. Half a point is below anything the eye or the hand can aim at, and it makes the + /// commonest drag deterministic. + internal static let crossingTolerance: CGFloat = 0.5 + internal static func settledDestination( forLocation location: CGFloat, tabWidth: CGFloat, @@ -102,7 +111,9 @@ internal enum EditorTabReorderResolver { func hasCrossed(_ index: Int) -> Bool { let centre = (CGFloat(index) + 0.5) * tabWidth - return candidate > currentIndex ? location >= centre : location <= centre + return candidate > currentIndex + ? location >= centre - crossingTolerance + : location <= centre + crossingTolerance } let crossable = candidate > currentIndex diff --git a/TablePro/Core/Services/Infrastructure/EditorTabStripPaneController.swift b/TablePro/Core/Services/Infrastructure/EditorTabStripPaneController.swift new file mode 100644 index 000000000..f36891d56 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/EditorTabStripPaneController.swift @@ -0,0 +1,67 @@ +// +// EditorTabStripPaneController.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// One connection's tab strip pane: an AppKit view that owns the pointer, wrapped around the +/// SwiftUI view that draws. +/// +/// It is a pane like the other three, built and kept alive per connection, so it carries the same +/// `sizingOptions = []` firewall that stops tab content publishing a minimum width the window's +/// split dividers cannot beat (#1872). +/// +/// The height is published rather than fixed. A wrapped strip is taller than a scrolling one, and +/// `preferredContentSize` is the documented way for a child to tell its parent so; the titlebar +/// accessory grows the band from it, which is what keeps the content below laid out around the +/// strip instead of behind it. +@MainActor +internal final class EditorTabStripPaneController: NSViewController { + internal let interaction = EditorTabStripInteraction() + + private let hosting = NSHostingController(rootView: AnyView(Color.clear)) + + internal var rootView: AnyView { + get { hosting.rootView } + set { hosting.rootView = newValue } + } + + override internal func loadView() { + let surface = EditorTabInteractionView(interaction: interaction) + surface.onRowCountChanged = { [weak self] rows in + self?.publishHeight(forRowCount: rows) + } + view = surface + + hosting.sizingOptions = [] + addChild(hosting) + let pane = hosting.view + pane.translatesAutoresizingMaskIntoConstraints = false + surface.addSubview(pane) + NSLayoutConstraint.activate([ + pane.leadingAnchor.constraint(equalTo: surface.leadingAnchor), + pane.trailingAnchor.constraint(equalTo: surface.trailingAnchor), + pane.topAnchor.constraint(equalTo: surface.topAnchor), + pane.bottomAnchor.constraint(equalTo: surface.bottomAnchor), + ]) + publishHeight(forRowCount: 1) + } + + /// Empties the pane the way every other one is emptied, including the explicit layout pass a + /// detached hosting controller needs before SwiftUI will dismantle its tree. + internal func teardown() { + interaction.commands = nil + rootView = AnyView(Color.clear) + hosting.view.layoutSubtreeIfNeeded() + view.removeFromSuperview() + removeFromParent() + } + + private func publishHeight(forRowCount rows: Int) { + let height = EditorTabStripLayout.bandHeight(forRowCount: rows) + guard preferredContentSize.height != height else { return } + preferredContentSize = CGSize(width: 0, height: height) + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift index 1cf9b8863..18462475f 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+TabStripAccessory.swift @@ -80,23 +80,21 @@ internal extension MainSplitViewController { /// `commandActions` is read at click time rather than captured, because it only exists once /// the detail pane has appeared and this strip is built alongside that pane, not after it. /// The workspace is held weakly: it owns the hosting controller these closures live in. + /// + /// The command set is handed to the pane's interaction object rather than to the SwiftUI view, + /// because the view is no longer what receives a press. AppKit owns the pointer over the + /// strip and reaches the app through exactly these closures. @ViewBuilder private func buildTabStripView(for workspace: ConnectionWorkspace) -> some View { if let sessionState = workspace.sessionState { + let interaction = workspace.panes.tabStrip.interaction + let _ = configure(interaction, for: workspace, sessionState: sessionState) EditorTabStrip( tabManager: sessionState.tabManager, + interaction: interaction, containerTarget: workspace.connection.flatMap { PluginManager.shared.containerSwitchTarget(for: $0.type) }, - onClose: { [weak workspace] id in - workspace?.sessionState?.coordinator.commandActions?.closeTab(id: id) - }, - onCloseOthers: { [weak workspace] id in - workspace?.sessionState?.coordinator.commandActions?.closeOtherTabs(anchoredOn: id) - }, - onCloseAll: { [weak workspace] in - workspace?.sessionState?.coordinator.commandActions?.closeAllTabs() - }, onNewTab: { [weak workspace] in workspace?.sessionState?.coordinator.commandActions?.newTab() } @@ -105,4 +103,45 @@ internal extension MainSplitViewController { Color.clear } } + + private func configure( + _ interaction: EditorTabStripInteraction, + for workspace: ConnectionWorkspace, + sessionState: SessionStateFactory.SessionState + ) { + let manager = sessionState.tabManager + let target = workspace.connection.flatMap { PluginManager.shared.containerSwitchTarget(for: $0.type) } + interaction.commands = EditorTabCommands( + activate: { [weak manager] id in manager?.selectedTabId = id }, + keepOpen: { [weak manager] id in manager?.promotePreviewTab(id: id) }, + canKeepOpen: { [weak manager] id in manager?.canPromotePreviewTab(id: id) ?? false }, + close: { [weak workspace] id in + workspace?.sessionState?.coordinator.commandActions?.closeTab(id: id) + }, + closeOthers: { [weak workspace] id in + workspace?.sessionState?.coordinator.commandActions?.closeOtherTabs(anchoredOn: id) + }, + closeAll: { [weak workspace] in + workspace?.sessionState?.coordinator.commandActions?.closeAllTabs() + }, + moveTab: { [weak manager] id, destination in manager?.moveTab(id: id, to: destination) }, + canMove: { [weak manager] id, offset in manager?.canMoveTab(id: id, by: offset) ?? false }, + moveBy: { [weak manager] id, offset in manager?.moveTab(id: id, by: offset) }, + tearOff: { _ in }, + canTearOff: { _ in false }, + /// The resolver's description, not the drawn title: a table tab carries its database + /// and schema there even when the short title is unique, and the tooltip is where a + /// truncated or duplicated name is told apart. + tooltip: { [weak manager] id in + guard let manager, let tab = manager.tabs.first(where: { $0.id == id }) else { return "" } + let description = EditorTabLabelResolver.resolve(tabs: manager.tabs, target: target)[id]? + .description ?? tab.title + guard tab.isPreview else { return description } + return String( + format: String(localized: "%@\nPreview tab. Double-click to keep it open."), + description + ) + } + ) + } } diff --git a/TablePro/Core/Services/Infrastructure/WorkspacePaneHost.swift b/TablePro/Core/Services/Infrastructure/WorkspacePaneHost.swift index e04b6a13c..289adecb2 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspacePaneHost.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspacePaneHost.swift @@ -26,6 +26,15 @@ internal final class WorkspacePaneHost: NSViewController { view = NSView() } + /// A pane can publish a height, which the editor tab strip does when it wraps onto more rows. + /// `NSViewController` only tells the immediate parent, so this container passes it on to + /// whatever is hosting it. + override internal func preferredContentSizeDidChange(for viewController: NSViewController) { + super.preferredContentSizeDidChange(for: viewController) + guard viewController === shown else { return } + preferredContentSize = viewController.preferredContentSize + } + internal func show(_ controller: NSViewController?) { guard shown !== controller else { return } @@ -35,8 +44,12 @@ internal final class WorkspacePaneHost: NSViewController { } shown = controller - guard let controller else { return } + guard let controller else { + preferredContentSize = .zero + return + } addChild(controller) + preferredContentSize = controller.preferredContentSize let pane = controller.view pane.translatesAutoresizingMaskIntoConstraints = false view.addSubview(pane) diff --git a/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift b/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift index 442c75762..0687dec99 100644 --- a/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift +++ b/TablePro/Core/Services/Infrastructure/WorkspacePanes.swift @@ -49,7 +49,11 @@ internal final class WorkspacePanes { /// connection, even though the window shows it in the titlebar accessory rather than in a /// split item. Holding it here is what gives it the same `sizingOptions` firewall and the /// same teardown as everything else the connection owns. - internal let tabStrip: NSHostingController + /// + /// It is the one pane that is not a bare hosting controller. AppKit owns the pointer over the + /// strip, so the SwiftUI view is wrapped in the view that owns it; see + /// `EditorTabStripPaneController`. + internal let tabStrip: EditorTabStripPaneController /// Written by the one function that produces pane content, and read by the one that decides /// whether it has to run. `nil` means the panes hold nothing anybody has vouched for. @@ -59,14 +63,14 @@ internal final class WorkspacePanes { detail = NSHostingController(rootView: AnyView(Color.clear)) inspector = NSHostingController(rootView: AnyView(Color.clear)) sidebar = NSHostingController(rootView: AnyView(Color.clear)) - tabStrip = NSHostingController(rootView: AnyView(Color.clear)) + tabStrip = EditorTabStripPaneController() for pane in panes { pane.sizingOptions = [] } } private var panes: [NSHostingController] { - [detail, inspector, sidebar, tabStrip] + [detail, inspector, sidebar] } internal func markRendered(_ key: WorkspacePaneRenderKey) { @@ -101,5 +105,6 @@ internal final class WorkspacePanes { pane.view.removeFromSuperview() pane.removeFromParent() } + tabStrip.teardown() } } diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index 7b872f463..0febaca94 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -350,14 +350,19 @@ struct HistorySettings: Codable, Equatable { /// Tab behavior settings struct TabSettings: Codable, Equatable { var enablePreviewTabs: Bool = true + /// What the strip does once the tabs stop fitting. `scroll` is the system's answer and the + /// default: every tab bar Apple ships keeps one row and scrolls it. + var overflow: EditorTabStripOverflow = .scroll static let `default` = TabSettings() - init(enablePreviewTabs: Bool = true) { + init(enablePreviewTabs: Bool = true, overflow: EditorTabStripOverflow = .scroll) { self.enablePreviewTabs = enablePreviewTabs + self.overflow = overflow } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) enablePreviewTabs = try container.decodeIfPresent(Bool.self, forKey: .enablePreviewTabs) ?? true + overflow = try container.decodeIfPresent(EditorTabStripOverflow.self, forKey: .overflow) ?? .scroll } } diff --git a/TablePro/Views/Main/EditorTabContextMenuBuilder.swift b/TablePro/Views/Main/EditorTabContextMenuBuilder.swift new file mode 100644 index 000000000..a003141bf --- /dev/null +++ b/TablePro/Views/Main/EditorTabContextMenuBuilder.swift @@ -0,0 +1,84 @@ +// +// EditorTabContextMenuBuilder.swift +// TablePro +// + +import AppKit + +/// The tab's contextual menu, built in AppKit because AppKit now owns the press. +/// +/// The strip keeps its SwiftUI `.contextMenu` as well, and the two never both fire: a right-click +/// resolves through `NSView.menu(for:)` on the view that owns the press, and the SwiftUI menu is +/// left as the route VoiceOver and Full Keyboard Access already take to the same commands. +@MainActor +internal enum EditorTabContextMenuBuilder { + internal static func menu(for tabId: UUID, commands: EditorTabCommands) -> NSMenu { + let menu = NSMenu() + menu.autoenablesItems = false + + append( + menu, + title: String(localized: "Keep Open"), + isEnabled: commands.canKeepOpen(tabId) + ) { commands.keepOpen(tabId) } + + menu.addItem(.separator()) + + append(menu, title: String(localized: "Close Tab")) { commands.close(tabId) } + append(menu, title: String(localized: "Close Other Tabs")) { commands.closeOthers(tabId) } + append(menu, title: String(localized: "Close All Tabs")) { commands.closeAll() } + + menu.addItem(.separator()) + + append( + menu, + title: String(localized: "Move Tab Left"), + isEnabled: commands.canMove(tabId, -1) + ) { commands.moveBy(tabId, -1) } + append( + menu, + title: String(localized: "Move Tab Right"), + isEnabled: commands.canMove(tabId, 1) + ) { commands.moveBy(tabId, 1) } + + menu.addItem(.separator()) + + append( + menu, + title: String(localized: "Move Tab to New Window"), + isEnabled: commands.canTearOff(tabId) + ) { commands.tearOff(tabId) } + + return menu + } + + private static func append( + _ menu: NSMenu, + title: String, + isEnabled: Bool = true, + action: @escaping () -> Void + ) { + let item = NSMenuItem(title: title, action: #selector(ClosureMenuTarget.fire), keyEquivalent: "") + let target = ClosureMenuTarget(action: action) + item.target = target + item.representedObject = target + item.isEnabled = isEnabled + menu.addItem(item) + } +} + +/// `NSMenuItem` holds its target weakly, so the closure needs an owner that outlives the menu. +/// `representedObject` is that owner: it is strong, it belongs to the item, and it goes when the +/// item does. +@MainActor +private final class ClosureMenuTarget: NSObject { + private let action: () -> Void + + internal init(action: @escaping () -> Void) { + self.action = action + } + + @objc internal func fire() { + action() + } +} diff --git a/TablePro/Views/Main/EditorTabInteractionView.swift b/TablePro/Views/Main/EditorTabInteractionView.swift new file mode 100644 index 000000000..1a19387bd --- /dev/null +++ b/TablePro/Views/Main/EditorTabInteractionView.swift @@ -0,0 +1,354 @@ +// +// EditorTabInteractionView.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// The single owner of pointer input over the editor tab strip. +/// +/// It is the strip's superview rather than a view laid over it, which is what keeps the SwiftUI +/// tabs where accessibility already finds them: a view mounted *over* the track covers every tab in +/// the accessibility tree however little it draws, measured, which is why the Escape monitor this +/// replaces had to be a background. A parent adds no sibling and shadows nothing. +/// +/// Two things follow from owning the press outright. `mouseDownCanMoveWindow` is false, so AppKit's +/// titlebar window drag can never take a gesture that started on a tab, at any position, on any +/// machine. And the click, the reorder and the tear-off are told apart inside one AppKit tracking +/// loop rather than by SwiftUI arbitrating between a `Button`, a `ScrollView` and a `DragGesture`, +/// which is what made a plain one-place drag do nothing about one time in seven. +/// +/// The loop is the shape AppKit controls have always used: `nextEvent(matching:)` with periodic +/// events mixed in, which is the documented way to keep autoscrolling while the pointer is held +/// still at the edge of the track. +@MainActor +internal final class EditorTabInteractionView: NSView { + internal let interaction: EditorTabStripInteraction + + /// Told to the pane controller, which publishes it as the band's height. A wrapped strip is + /// taller, and the titlebar accessory has to grow with it or the extra rows are drawn behind + /// the content. + internal var onRowCountChanged: ((Int) -> Void)? + + private var hoverTrackingArea: NSTrackingArea? + private var lastActivatedTabId: UUID? + + internal init(interaction: EditorTabStripInteraction) { + self.interaction = interaction + super.init(frame: .zero) + /// Every path that re-lays the run reports through here, not just this view's own layout. + /// A tab opened or closed while the strip is wrapped can cross a row boundary, and that + /// change arrives on the interaction rather than on a layout pass. + interaction.onRowCountChanged = { [weak self] rows in + self?.onRowCountChanged?(rows) + self?.needsLayout = true + } + } + + @available(*, unavailable) + internal required init?(coder: NSCoder) { + fatalError("EditorTabInteractionView does not support NSCoder init") + } + + /// The run layout, the SwiftUI drawing and this view's hit testing all measure y downward, so + /// none of the three has to flip the other two. + override internal var isFlipped: Bool { true } + + /// The strip never drags the window. The band around it keeps AppKit's default, so the empty + /// chrome either side of the track still moves the window the way Finder's tab bar does. + override internal var mouseDownCanMoveWindow: Bool { false } + + override internal func isAccessibilityElement() -> Bool { false } + + // MARK: - Geometry + + /// The track's viewport, which is everything the pointer owns. The new-tab button sits outside + /// it and stays an ordinary SwiftUI button. + internal var trackRect: CGRect { + let trailing = EditorTabStripLayout.stripInset + + EditorTabStripLayout.newTabButtonSize + + EditorTabStripLayout.trackSpacing + let width = max(bounds.width - EditorTabStripLayout.stripInset - trailing, 0) + return CGRect( + x: EditorTabStripLayout.stripInset, + y: 0, + width: width, + height: EditorTabStripLayout.trackHeight(forRowCount: interaction.run.rowCount) + ) + } + + private var viewportRect: CGRect { + trackRect.insetBy(dx: EditorTabStripLayout.trackPadding, dy: EditorTabStripLayout.trackPadding) + } + + private func contentPoint(from event: NSEvent) -> CGPoint { + contentPoint(fromViewPoint: convert(event.locationInWindow, from: nil)) + } + + private func contentPoint(fromViewPoint point: CGPoint) -> CGPoint { + CGPoint( + x: point.x - viewportRect.minX + interaction.contentOffset, + y: point.y - viewportRect.minY + ) + } + + override internal func layout() { + super.layout() + rebuildRun() + refreshHoverTracking() + } + + /// Re-measures the run. The row count reaches the pane controller from the interaction, which + /// is the one place every rebuild goes through. + internal func rebuildRun() { + interaction.updateRun(trackWidth: trackRect.width, count: interaction.tabIds.count) + interaction.clampContentOffset() + } + + // MARK: - Hit testing + + /// Claims the track and nothing else, so a press on a tab is this view's and a press on the + /// new-tab button, on the band's insets or on the chrome below the track is not. + override internal func hitTest(_ point: NSPoint) -> NSView? { + let local = convert(point, from: superview) + guard trackRect.contains(local) else { return super.hitTest(point) } + return self + } + + private func tabIndex(atViewPoint point: CGPoint) -> Int? { + EditorTabRunLayoutBuilder.index(at: contentPoint(fromViewPoint: point), in: interaction.run) + } + + private func tabId(at index: Int) -> UUID? { + let ids = interaction.displayedIds + guard ids.indices.contains(index) else { return nil } + return ids[index] + } + + private func isCloseButton(_ point: CGPoint, forTabAt index: Int) -> Bool { + guard let placement = interaction.run.placement(at: index) else { return false } + return EditorTabRunLayoutBuilder.closeButtonRect(in: placement.frame) + .contains(contentPoint(fromViewPoint: point)) + } + + // MARK: - Hover + + private func refreshHoverTracking() { + if let hoverTrackingArea { removeTrackingArea(hoverTrackingArea) } + let area = NSTrackingArea( + rect: trackRect, + options: [.activeInKeyWindow, .mouseEnteredAndExited, .mouseMoved], + owner: self + ) + addTrackingArea(area) + hoverTrackingArea = area + } + + override internal func mouseMoved(with event: NSEvent) { + updateHover(at: convert(event.locationInWindow, from: nil)) + } + + override internal func mouseExited(with event: NSEvent) { + interaction.setHovered(nil) + } + + private func updateHover(at point: CGPoint) { + guard let index = tabIndex(atViewPoint: point), let id = tabId(at: index) else { + interaction.setHovered(nil) + toolTip = nil + return + } + interaction.setHovered(id, overCloseButton: isCloseButton(point, forTabAt: index)) + toolTip = interaction.commands?.tooltip(id) + } + + // MARK: - Scrolling + + /// The track scrolls itself, because the view that owns the press owns the wheel too. A + /// wrapped run never overflows, so it ignores this. + override internal func scrollWheel(with event: NSEvent) { + let delta = abs(event.scrollingDeltaX) >= abs(event.scrollingDeltaY) + ? event.scrollingDeltaX + : event.scrollingDeltaY + guard delta != 0 else { return } + interaction.scroll(by: -delta) + /// The run moved under a pointer that did not, so the tab it is over, its close target and + /// the tooltip all changed. Without this the close affordance is drawn on one tab while a + /// click in that slot hits another. + updateHover(at: convert(event.locationInWindow, from: nil)) + } + + // MARK: - Contextual menu + + override internal func menu(for event: NSEvent) -> NSMenu? { + let point = convert(event.locationInWindow, from: nil) + guard let index = tabIndex(atViewPoint: point), let id = tabId(at: index), + let commands = interaction.commands + else { return nil } + return EditorTabContextMenuBuilder.menu(for: id, commands: commands) + } + + // MARK: - Tracking + + override internal func mouseDown(with event: NSEvent) { + let start = convert(event.locationInWindow, from: nil) + guard let index = tabIndex(atViewPoint: start), let pressedId = tabId(at: index) else { return } + + if isCloseButton(start, forTabAt: index) { + trackCloseButton(startingAt: start, tabId: pressedId) + return + } + + activate(pressedId, click: EditorTabClick(event: event)) + trackDrag(startingAt: start, tabId: pressedId) + } + + /// A press on the close button commits only if the pointer is still on it when the button comes + /// up, which is what every AppKit button does and what lets a user change their mind. + private func trackCloseButton(startingAt start: CGPoint, tabId: UUID) { + guard let window else { return } + var isInside = true + while let next = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) { + let point = convert(next.locationInWindow, from: nil) + if next.type == .leftMouseUp { + if isInside, let index = interaction.displayedIds.firstIndex(of: tabId), + isCloseButton(point, forTabAt: index) { + interaction.commands?.close(tabId) + } + return + } + guard let index = interaction.displayedIds.firstIndex(of: tabId) else { return } + isInside = isCloseButton(point, forTabAt: index) + } + } + + private func trackDrag(startingAt start: CGPoint, tabId: UUID) { + guard let window else { return } + var gesture: EditorTabGesture? + NSEvent.startPeriodicEvents(afterDelay: 0.08, withPeriod: 0.02) + defer { NSEvent.stopPeriodicEvents() } + + var latest = start + while let next = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp, .keyDown, .periodic]) { + switch next.type { + case .keyDown where next.keyCode == KeyCode.escape.rawValue: + interaction.clearReorder() + consumeRemainingDrag(in: window) + return + case .keyDown: + /// Every key-down matches the mask, and taking one out of the queue without + /// dispatching it would swallow `Cmd+W`, `Cmd+T` and the tab-switch shortcuts for + /// as long as the button is held. The reorder already survives a tab opening or + /// closing under it, so the commands stay live. + NSApp.sendEvent(next) + case .leftMouseUp: + /// The release carries a location of its own, and a fast drag can cross a + /// neighbour's midpoint between the last drag sample and it. Resolving from + /// `latest` alone commits the order the pointer had a frame ago, which is the + /// short-move-or-nothing this change exists to remove. + latest = convert(next.locationInWindow, from: nil) + gesture = resolveGesture(gesture, from: start, to: latest, tabId: tabId) + if gesture == .reorder { applyReorder(at: latest, tabId: tabId) } + finishDrag(gesture, tabId: tabId) + return + case .periodic: + guard gesture == .reorder else { continue } + autoscroll(towards: latest) + applyReorder(at: latest, tabId: tabId) + case .leftMouseDragged: + latest = convert(next.locationInWindow, from: nil) + gesture = resolveGesture(gesture, from: start, to: latest, tabId: tabId) + guard gesture == .reorder else { continue } + autoscroll(towards: latest) + applyReorder(at: latest, tabId: tabId) + default: + continue + } + } + } + + /// A press becomes a reorder once it travels the distance AppKit uses to tell a click from a + /// drag, and a tear-off once it leaves the band by more than a tab's height again. A gesture + /// never goes back to being a click. + private func resolveGesture( + _ current: EditorTabGesture?, + from start: CGPoint, + to point: CGPoint, + tabId: UUID + ) -> EditorTabGesture? { + if canTearOff(tabId), abs(point.y - start.y) >= EditorTabStripLayout.tearOffThreshold { + interaction.markTearingOff(tabId) + return .tearOff + } + if current == .tearOff { + interaction.markTearingOff(nil) + } + guard current == .reorder || hypot(point.x - start.x, point.y - start.y) >= EditorTabStripLayout.reorderThreshold + else { return current } + interaction.beginReorder(of: tabId) + return .reorder + } + + private func canTearOff(_ tabId: UUID) -> Bool { + interaction.commands?.canTearOff(tabId) ?? false + } + + private func applyReorder(at point: CGPoint, tabId: UUID) { + let location = EditorTabRunLayoutBuilder.linearLocation( + of: contentPoint(fromViewPoint: point), + in: interaction.run + ) + withMotion(.easeInOut(duration: 0.18)) { + interaction.updateReorder(toLinearLocation: location) + } + } + + /// Scrolls the track while the pointer is held near either edge, so a tab can be dragged to a + /// place that is not currently on screen. Without it a reorder stops at the viewport and the + /// tabs a user opened first, the ones #2438 is about, cannot be reached at all. + private func autoscroll(towards point: CGPoint) { + guard interaction.overflow == .scroll else { return } + let leadingEdge = viewportRect.minX + EditorTabStripLayout.autoscrollMargin + let trailingEdge = viewportRect.maxX - EditorTabStripLayout.autoscrollMargin + if point.x < leadingEdge { + interaction.scroll(by: -EditorTabStripLayout.autoscrollStep) + } else if point.x > trailingEdge { + interaction.scroll(by: EditorTabStripLayout.autoscrollStep) + } + } + + private func finishDrag(_ gesture: EditorTabGesture?, tabId: UUID) { + switch gesture { + case .tearOff: + interaction.markTearingOff(nil) + interaction.clearReorder() + interaction.commands?.tearOff(tabId) + case .reorder: + interaction.commitReorder() + default: + interaction.clearReorder() + } + } + + /// Escape abandons the drag, and the pointer is still down. Swallowing the rest of the gesture + /// is what keeps the release from starting a fresh one. + private func consumeRemainingDrag(in window: NSWindow) { + while let next = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) { + if next.type == .leftMouseUp { return } + } + } + + private func activate(_ tabId: UUID, click: EditorTabClick?) { + guard let commands = interaction.commands else { return } + let activation = EditorTabActivationResolver.resolve( + click: click, + tabId: tabId, + lastActivatedTabId: lastActivatedTabId + ) + lastActivatedTabId = tabId + commands.activate(tabId) + guard activation == .selectAndKeep else { return } + commands.keepOpen(tabId) + } +} diff --git a/TablePro/Views/Main/EditorTabRunLayout.swift b/TablePro/Views/Main/EditorTabRunLayout.swift new file mode 100644 index 000000000..3bf417309 --- /dev/null +++ b/TablePro/Views/Main/EditorTabRunLayout.swift @@ -0,0 +1,145 @@ +// +// EditorTabRunLayout.swift +// TablePro +// + +import CoreGraphics +import Foundation +import SwiftUI + +/// What the strip does once the tabs stop fitting. +/// +/// `scroll` is the system's answer and stays the default: every tab bar Apple ships, in Finder, +/// Safari and Terminal, keeps one row and scrolls it. `rows` is offered because a tab bar that +/// scrolls hides the tabs a user opened first, which is the complaint #2438 was filed about, and +/// it is opt-in for the same reason. +internal enum EditorTabStripOverflow: String, CaseIterable, Codable, Sendable { + case scroll + case rows + + internal var displayName: String { + switch self { + case .scroll: return String(localized: "Scroll them") + case .rows: return String(localized: "Wrap onto more rows") + } + } +} + +/// Where one tab sits in the run, in the track's content space. +internal struct EditorTabPlacement: Equatable { + internal let index: Int + internal let row: Int + internal let frame: CGRect +} + +/// The whole run of tabs: where each one sits, how many rows it took, and how big the content is. +/// +/// This is the single source of geometry for the strip. The AppKit view that owns the pointer +/// hit-tests against it, the reorder resolves against it, and SwiftUI draws from it, so a tab the +/// user can see, a tab the pointer can hit and a tab the drag can target are the same rectangle by +/// construction rather than by three views agreeing. +internal struct EditorTabRunLayout: Equatable { + internal let placements: [EditorTabPlacement] + internal let tabWidth: CGFloat + internal let tabsPerRow: Int + internal let rowCount: Int + internal let contentSize: CGSize + + internal static let empty = EditorTabRunLayout( + placements: [], + tabWidth: 0, + tabsPerRow: 1, + rowCount: 1, + contentSize: .zero + ) + + internal func placement(at index: Int) -> EditorTabPlacement? { + placements.first { $0.index == index } + } +} + +/// The pure geometry of the run, kept apart from both the AppKit view and the SwiftUI one. +internal enum EditorTabRunLayoutBuilder { + /// Lays the run out inside a track of `width`. + /// + /// `scroll` keeps one row and lets the content run past the viewport. `rows` fits as many tabs + /// per row as `minimumTabWidth` allows and wraps, so the content never runs wider than the + /// track and no tab is ever off screen. + internal static func run( + forTrack width: CGFloat, + count: Int, + overflow: EditorTabStripOverflow + ) -> EditorTabRunLayout { + guard count > 0, width > 0 else { return .empty } + + let usable = max(width - EditorTabStripLayout.trackPadding * 2, 0) + let perRow = tabsPerRow(usable: usable, count: count, overflow: overflow) + let rows = Int(ceil(Double(count) / Double(max(perRow, 1)))) + let tabWidth = overflow == .scroll + ? EditorTabStripLayout.tabWidth(forTrack: width, count: count) + : usable / CGFloat(max(perRow, 1)) + + let placements = (0 ..< count).map { index -> EditorTabPlacement in + let row = index / max(perRow, 1) + let column = index % max(perRow, 1) + return EditorTabPlacement( + index: index, + row: row, + frame: CGRect( + x: CGFloat(column) * tabWidth, + y: CGFloat(row) * EditorTabStripLayout.rowStride, + width: tabWidth, + height: EditorTabStripLayout.tabHeight + ) + ) + } + + return EditorTabRunLayout( + placements: placements, + tabWidth: tabWidth, + tabsPerRow: max(perRow, 1), + rowCount: max(rows, 1), + contentSize: CGSize( + width: overflow == .scroll ? tabWidth * CGFloat(count) : usable, + height: CGFloat(max(rows, 1)) * EditorTabStripLayout.rowStride + - EditorTabStripLayout.rowSpacing + ) + ) + } + + private static func tabsPerRow(usable: CGFloat, count: Int, overflow: EditorTabStripOverflow) -> Int { + guard overflow == .rows else { return count } + let fitting = Int(floor(usable / EditorTabStripLayout.minimumTabWidth)) + return max(min(fitting, count), 1) + } + + /// The tab a point in content space lands on, or nil for the gaps a wrapped last row leaves. + internal static func index(at point: CGPoint, in run: EditorTabRunLayout) -> Int? { + run.placements.first { $0.frame.contains(point) }?.index + } + + /// The close button's target, which the pointer owner needs because the button is drawn by + /// SwiftUI but no longer clicked through it. + internal static func closeButtonRect(in frame: CGRect) -> CGRect { + CGRect( + x: frame.minX + EditorTabStripLayout.accessoryInset, + y: frame.midY - EditorTabStripLayout.accessoryWidth / 2, + width: EditorTabStripLayout.accessoryWidth, + height: EditorTabStripLayout.accessoryWidth + ) + } + + /// Flattens a point in the run onto the single axis the reorder resolves along. + /// + /// One row is already that axis. A wrapped run is not, so a point is projected onto the run it + /// would have been had it never wrapped: the row it is over contributes a whole row of tabs, + /// and the offset inside the row contributes the rest. The reorder rule then stays the one + /// rule, with its midpoint hysteresis, rather than growing a second one for rows. + internal static func linearLocation(of point: CGPoint, in run: EditorTabRunLayout) -> CGFloat { + guard run.tabWidth > 0 else { return 0 } + guard run.rowCount > 1 else { return point.x } + let row = min(max(Int(floor(point.y / EditorTabStripLayout.rowStride)), 0), run.rowCount - 1) + let clampedX = min(max(point.x, 0), run.tabWidth * CGFloat(run.tabsPerRow)) + return CGFloat(row * run.tabsPerRow) * run.tabWidth + clampedX + } +} diff --git a/TablePro/Views/Main/EditorTabStrip.swift b/TablePro/Views/Main/EditorTabStrip.swift index aca509461..d758f2260 100644 --- a/TablePro/Views/Main/EditorTabStrip.swift +++ b/TablePro/Views/Main/EditorTabStrip.swift @@ -31,45 +31,23 @@ import SwiftUI /// elements" (WWDC25 session 219). The band is already the system's glass, so these fills are the /// top layer on it rather than a second pane of it. internal struct EditorTabStrip: View { - /// The space a reorder measures the pointer in. Named on the scroll view's content, so it spans - /// the whole run of tabs rather than the part of it currently on screen. - private static let trackContentSpace = "editor-tab-track-content" - internal let tabManager: QueryTabManager + /// The pointer's owner. AppKit measures the run and drives every press; this view draws what + /// that produced. Nothing here reads a mouse. + internal let interaction: EditorTabStripInteraction /// The dimension this engine's tabs are anchored to, so a label can name the container it /// shares a title with. Resolved by the window, because a view has no business asking the /// plugin registry what kind of container a connection has. internal let containerTarget: ContainerSwitchTarget? - internal let onClose: (UUID) -> Void - internal let onCloseOthers: (UUID) -> Void - internal let onCloseAll: () -> Void internal let onNewTab: () -> Void /// Left unset by the app, which reads the two accessibility settings instead. A test sets it, /// because glass does not rasterise. internal var surfaceStyle: EditorTabStripSurfaceStyle? - @State private var hoveredTabId: UUID? - /// The reorder in flight, holding both the order the strip draws and the order it came from. - /// Held here rather than in the item, because the order is a property of the row: the strip - /// draws from it, and the separators are hidden for the whole strip while a tab is in flight so - /// a line does not appear between two tabs that are mid-swap. - /// - /// The manager is not written until the pointer comes up. Writing it live, as this did before, - /// saved the tab list to disk on every neighbour crossed, so a drag the user abandoned was - /// already committed and could not be taken back. - @State private var reorder: EditorTabReorder? - /// Latched by a cancel and cleared only when the gesture itself ends. `reorder == nil` cannot - /// carry this: it means both "not started" and "cancelled", so the next `onChanged` of a - /// gesture the user had already abandoned started a fresh reorder from the manager's order and - /// the release committed it. Escape read as working and then reordered the strip anyway. - @State private var reorderCancelled = false - /// The tab the previous click activated, so the second click of a double-click can be told - /// from a click on a neighbour that happened to land inside the double-click window. - @State private var lastActivatedTabId: UUID? - /// A tab whose selection came from a click on the tab itself, which must not be recentred. - /// The track scrolls once the tabs stop fitting, and sliding the clicked tab to the middle - /// takes it out from under a second click that is already on its way. - @State private var clickSelectedTabId: UUID? + /// Read here rather than pushed in at build time, so changing the preference re-lays every + /// open strip at once instead of the next time an unrelated pane happens to rebuild. + @State private var settings = AppSettingsManager.shared + @Environment(\.controlActiveState) private var controlActiveState @Environment(\.colorSchemeContrast) private var colorSchemeContrast @Environment(\.accessibilityReduceTransparency) private var reduceTransparency @@ -83,34 +61,37 @@ internal struct EditorTabStrip: View { isWindowActive: isWindowActive, prefersSolidSurfaces: prefersSolidSurfaces ) + .frame(height: EditorTabStripLayout.trackHeight) } - .frame(height: EditorTabStripLayout.trackHeight) + .frame(height: trackHeight, alignment: .top) } .padding(.horizontal, EditorTabStripLayout.stripInset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .onChange(of: controlActiveState) { _, state in - if state == .inactive { hoveredTabId = nil } - } /// 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. - .onChange(of: tabManager.tabs.map(\.id)) { _, ids in - if let hoveredTabId, !ids.contains(hoveredTabId) { self.hoveredTabId = nil } - if let lastActivatedTabId, !ids.contains(lastActivatedTabId) { self.lastActivatedTabId = nil } - dropClosedTabsFromReorder(keeping: ids) + .onChange(of: tabManager.tabs.map(\.id), initial: true) { _, ids in + interaction.dropClosedTabs(keeping: ids) + } + .onChange(of: settings.tabs.overflow, initial: true) { _, style in + interaction.overflow = style + } + /// Cmd+1..9, opening a table from the sidebar and closing a tab can all land on a tab that + /// is scrolled out of sight, so the selection pulls itself into view. + .onChange(of: tabManager.selectedTabId) { _, newValue in + guard let newValue else { return } + withMotion(.easeOut(duration: 0.15)) { + interaction.revealTab(id: newValue) + } } - /// The pointer and the keyboard are independent streams, so `Cmd+W` can close the tab that - /// is mid-drag, and `Cmd+T` can add one beside it. Neither may reach the commit: a stale id - /// would put back a tab that is gone, and a new one would be left out of the order. - /// - /// Switching connection unparents this pane while the state survives, which SwiftUI reports - /// as `onDisappear`, so the drag is ended there too. Nothing here needs rebuilding on the - /// way back: a reorder belongs to the gesture, not to the view's lifetime. - .onDisappear(perform: cancelReorder) .accessibilityElement(children: .contain) .accessibilityLabel(Text("Editor Tabs")) .accessibilityAddTraits(.isTabBar) } + private var trackHeight: CGFloat { + EditorTabStripLayout.trackHeight(forRowCount: interaction.run.rowCount) + } + /// One container for every glass element in the strip. Glass cannot sample glass across /// containers, so the track, the selected tab and the new-tab button light inconsistently /// when they are not grouped, and the button sits only four points from the track's edge. @@ -129,197 +110,76 @@ internal struct EditorTabStrip: View { } } + /// The tabs, each drawn at the rectangle `EditorTabRunLayout` gave it, shifted by however far + /// the track has scrolled. There is no `ScrollView` here on purpose: the view that owns the + /// 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 { - GeometryReader { proxy in - ScrollViewReader { scroller in - let tabs = displayedTabs - let labels = EditorTabLabelResolver.resolve(tabs: tabs, target: containerTarget) - let tabWidth = EditorTabStripLayout.tabWidth(forTrack: proxy.size.width, count: tabs.count) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - ForEach(Array(tabs.enumerated()), id: \.element.id) { index, tab in - item(for: tab, at: index, in: tabs, label: labels[tab.id], tabWidth: tabWidth) - .frame(width: tabWidth) - .id(tab.id) - } - } - /// Named on the content rather than on the track, so a pointer position is - /// measured against the whole run of tabs. A space named outside the scroll - /// view is viewport-relative, which would place the drag one tab further along - /// for every tab the track has scrolled past. - .coordinateSpace(name: Self.trackContentSpace) - } - /// Cmd+1..9, opening a table from the sidebar and closing a tab can all land on - /// a tab that is scrolled out of sight, so the selection pulls itself into view. - .onChange(of: tabManager.selectedTabId) { _, newValue in - guard let newValue else { return } - guard clickSelectedTabId != newValue else { - clickSelectedTabId = nil - return - } - withMotion(.easeOut(duration: 0.15)) { - scroller.scrollTo(newValue, anchor: .center) - } - } + ZStack(alignment: .topLeading) { + ForEach(Array(displayedTabs.enumerated()), id: \.element.id) { index, tab in + item(for: tab, at: index, in: displayedTabs, label: labels[tab.id]) } - .frame(height: EditorTabStripLayout.tabHeight) - /// Clipped to the same shape the tabs are drawn as, so a tab scrolled under the - /// track's rounded end is cut by that curve instead of squaring it off. - .clipShape(EditorTabStripLayout.tabShape) - .padding(EditorTabStripLayout.trackPadding) } - .frame(height: EditorTabStripLayout.trackHeight) - .trackSurface() - .background(EditorTabReorderCancelMonitor(isReordering: reorder != nil, onCancel: cancelReorder)) + .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)) + .padding(EditorTabStripLayout.trackPadding) + .frame(height: trackHeight) + .trackSurface(rowCount: interaction.run.rowCount) } - private func item( - for tab: QueryTab, - at index: Int, - in tabs: [QueryTab], - label: EditorTabLabelResolver.Label?, - tabWidth: CGFloat - ) -> some View { - EditorTabStripItem( - tab: tab, - label: label ?? EditorTabLabelResolver.Label(text: tab.title, description: tab.title), - isSelected: tabManager.selectedTab?.id == tab.id, - isHovered: hoveredTabId == tab.id, - isWindowActive: isWindowActive, - showsLeadingSeparator: EditorTabStripLayout.showsSeparator( - before: index, - tabIds: tabs.map(\.id), - selectedId: tabManager.selectedTab?.id, - hoveredId: hoveredTabId, - isReordering: reorder != nil - ), - position: index + 1, - count: tabs.count, - onHover: { hovering in - if hovering { - hoveredTabId = tab.id - } else if hoveredTabId == tab.id { - hoveredTabId = nil - } - }, - onActivate: { activate(tab.id) }, - onClose: { onClose(tab.id) }, - onCloseOthers: { onCloseOthers(tab.id) }, - onCloseAll: onCloseAll, - canKeepOpen: tabManager.canPromotePreviewTab(id: tab.id), - onKeepOpen: { tabManager.promotePreviewTab(id: tab.id) }, - canMoveLeft: tabManager.canMoveTab(id: tab.id, by: -1), - canMoveRight: tabManager.canMoveTab(id: tab.id, by: 1), - onMoveLeft: { tabManager.moveTab(id: tab.id, by: -1) }, - onMoveRight: { tabManager.moveTab(id: tab.id, by: 1) } - ) - .opacity(reorder?.draggedId == tab.id ? EditorTabStripLayout.draggingOpacity : 1) - /// Simultaneous rather than plain, so the tab's own button keeps the click that selects it. - /// A plain `.gesture` here would sit below that button and never see the press at all, and - /// a high-priority one would take the click away from it. - .simultaneousGesture( - DragGesture( - minimumDistance: EditorTabStripLayout.reorderThreshold, - coordinateSpace: .named(Self.trackContentSpace) - ) - .onChanged { value in updateReorder(of: tab.id, toward: value.location.x, tabWidth: tabWidth) } - .onEnded { _ in endReorder() } - ) - } - - /// The order the strip draws: the reorder's while one is in flight, the manager's otherwise. private var displayedTabs: [QueryTab] { - guard let reorder else { return tabManager.tabs } let byId = Dictionary(tabManager.tabs.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) - return reorder.order.compactMap { byId[$0] } - } - - /// Starts the reorder on the first movement past the threshold, then moves the tab as the - /// pointer passes each neighbour's centre. - private func updateReorder(of tabId: UUID, toward location: CGFloat, tabWidth: CGFloat) { - guard !reorderCancelled else { return } - var live = reorder ?? EditorTabReorder(draggedId: tabId, order: tabManager.tabs.map(\.id)) - guard live.draggedId == tabId, let currentIndex = live.destinationIndex else { return } - let destination = EditorTabReorderResolver.settledDestination( - forLocation: location, - tabWidth: tabWidth, - currentIndex: currentIndex, - count: live.order.count - ) - if let destination { - withMotion(.easeInOut(duration: 0.18)) { - live.move(to: destination) - reorder = live - } - return - } - reorder = live + return interaction.displayedIds.compactMap { byId[$0] } } - /// The gesture is over: commit what it asked for, unless it was cancelled. Releasing is also - /// the only thing that lifts the cancel latch, so a gesture abandoned by Escape stays abandoned - /// however far the pointer travels afterwards. - private func endReorder() { - defer { reorderCancelled = false } - guard !reorderCancelled else { return } - commitReorder() + private var labels: [UUID: EditorTabLabelResolver.Label] { + EditorTabLabelResolver.resolve(tabs: displayedTabs, target: containerTarget) } - /// Writes the order to the manager once, on release, or not at all when nothing moved. This is - /// the only place a drag reaches `QueryTabManager`, which is what keeps a reorder out of the - /// persisted tab list until the user has finished asking for it. - private func commitReorder() { - guard let reorder else { return } - defer { self.reorder = nil } - guard reorder.movedFromOriginal, let destination = reorder.destinationIndex else { return } - tabManager.moveTab(id: reorder.draggedId, to: destination) - } - - /// Escape, a closed tab, or the pane going away. The strip goes back to the manager's order, - /// which the drag never wrote to, so there is nothing to undo. - private func cancelReorder() { - guard reorder != nil else { return } - reorderCancelled = true - withMotion(.easeInOut(duration: 0.18)) { - reorder = nil + @ViewBuilder + private func item( + for tab: QueryTab, + at index: Int, + in tabs: [QueryTab], + label: EditorTabLabelResolver.Label? + ) -> some View { + if let placement = interaction.run.placement(at: index) { + EditorTabStripItem( + tab: tab, + label: label ?? EditorTabLabelResolver.Label(text: tab.title, description: tab.title), + isSelected: tabManager.selectedTab?.id == tab.id, + isHovered: interaction.hoveredTabId == tab.id, + isCloseHovered: interaction.hoveredCloseTabId == tab.id, + isWindowActive: isWindowActive, + showsLeadingSeparator: EditorTabStripLayout.showsSeparator( + before: index, + tabIds: tabs.map(\.id), + selectedId: tabManager.selectedTab?.id, + hoveredId: interaction.hoveredTabId, + isReordering: interaction.reorder != nil + ), + position: index + 1, + count: tabs.count, + commands: interaction.commands + ) + .opacity(opacity(of: tab)) + .frame(width: placement.frame.width, height: placement.frame.height) + .offset( + x: placement.frame.minX - interaction.contentOffset, + y: placement.frame.minY + ) } } - /// A tab that closed mid-drag leaves both orders, and the drag ends outright when the tab being - /// dragged is the one that went. A tab opened mid-drag is not folded in: the order it would - /// join is already stale, so the drag is abandoned rather than committed against a list the - /// user did not drag over. - private func dropClosedTabsFromReorder(keeping ids: [UUID]) { - guard var live = reorder else { return } - guard live.removingClosedTabs(keeping: ids), Set(live.order) == Set(ids) else { - cancelReorder() - return - } - reorder = live - } - - /// Selects the tab, and keeps it when the click that got here was the second of a double-click. - /// - /// The click count is read off the event AppKit is currently dispatching rather than arbitrated - /// by a SwiftUI gesture, which is what `NSTableView` does with `action` and `doubleAction`. - /// Measured against the shipping strip: a `TapGesture(count: 2)` in any composition holds the - /// selection back 371ms on every click and drops it entirely on the double, and a - /// `simultaneousGesture` selects twice; reading the event costs the same 22ms as selecting. - private func activate(_ tabId: UUID) { - let activation = EditorTabActivationResolver.resolve( - click: EditorTabClick(event: NSApp.currentEvent), - tabId: tabId, - lastActivatedTabId: lastActivatedTabId - ) - lastActivatedTabId = tabId - /// Set only when the selection is about to change, so the flag is always consumed by the - /// `onChange` it is meant for rather than left behind to swallow a later Cmd+1. - if tabManager.selectedTabId != tabId { - clickSelectedTabId = tabId - } - tabManager.selectedTabId = tabId - guard activation == .selectAndKeep else { return } - tabManager.promotePreviewTab(id: tabId) + /// The dragged tab fades enough to read as lifted out of the strip, and a tab being torn off + /// fades further so the gesture says what it is about to do before the mouse comes up. + private func opacity(of tab: QueryTab) -> CGFloat { + if interaction.tearingOffTabId == tab.id { return EditorTabStripLayout.tearingOffOpacity } + return interaction.reorder?.draggedId == tab.id ? EditorTabStripLayout.draggingOpacity : 1 } private var isWindowActive: Bool { @@ -341,21 +201,15 @@ private struct EditorTabStripItem: View { let label: EditorTabLabelResolver.Label let isSelected: Bool let isHovered: Bool + let isCloseHovered: Bool let isWindowActive: Bool let showsLeadingSeparator: Bool let position: Int let count: Int - let onHover: (Bool) -> Void - let onActivate: () -> Void - let onClose: () -> Void - let onCloseOthers: () -> Void - let onCloseAll: () -> Void - let canKeepOpen: Bool - let onKeepOpen: () -> Void - let canMoveLeft: Bool - let canMoveRight: Bool - let onMoveLeft: () -> Void - let onMoveRight: () -> Void + /// The same command set the pointer's owner drives. The controls below never receive a mouse + /// event any more, and exist for the keyboard, Full Keyboard Access and VoiceOver, which reach + /// them without one. + let commands: EditorTabCommands? var body: some View { ZStack { @@ -375,25 +229,23 @@ private struct EditorTabStripItem: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) - .onHover(perform: onHover) - .help(Text(tooltip)) .contextMenu { /// The double-click that keeps a tab is an editor idiom rather than a system one, so /// it needs a command beside it: a gesture with no menu equivalent cannot be found by /// a user who does not already expect it, and cannot be performed at all by VoiceOver. - Button(String(localized: "Keep Open"), action: onKeepOpen) + Button(String(localized: "Keep Open")) { commands?.keepOpen(tab.id) } .disabled(!canKeepOpen) Divider() - Button(String(localized: "Close Tab"), action: onClose) - Button(String(localized: "Close Other Tabs"), action: onCloseOthers) - Button(String(localized: "Close All Tabs"), action: onCloseAll) + Button(String(localized: "Close Tab")) { commands?.close(tab.id) } + Button(String(localized: "Close Other Tabs")) { commands?.closeOthers(tab.id) } + Button(String(localized: "Close All Tabs")) { commands?.closeAll() } Divider() /// Dragging is the usual way to reorder, and it is also the only way that needs a /// pointer. These give the same reordering to the keyboard and to VoiceOver, which /// reaches a context menu but cannot perform a drag. - Button(String(localized: "Move Tab Left"), action: onMoveLeft) + Button(String(localized: "Move Tab Left")) { commands?.moveBy(tab.id, -1) } .disabled(!canMoveLeft) - Button(String(localized: "Move Tab Right"), action: onMoveRight) + Button(String(localized: "Move Tab Right")) { commands?.moveBy(tab.id, 1) } .disabled(!canMoveRight) } .accessibilityElement(children: .combine) @@ -402,16 +254,16 @@ private struct EditorTabStripItem: View { .accessibilityLabel(Text(label.text)) .accessibilityValue(Text(positionDescription)) .accessibilityAddTraits(isSelected ? .isSelected : []) - .accessibilityAction(named: Text("Close Tab"), onClose) + .accessibilityAction(named: Text("Close Tab")) { commands?.close(tab.id) } /// Offered only where it does something, so the actions rotor matches the contextual menu /// rather than announcing a command that silently does nothing on a tab already kept. .accessibilityActions { if canKeepOpen { - Button(String(localized: "Keep Open"), action: onKeepOpen) + Button(String(localized: "Keep Open")) { commands?.keepOpen(tab.id) } } } - .accessibilityAction(named: Text("Move Tab Left")) { if canMoveLeft { onMoveLeft() } } - .accessibilityAction(named: Text("Move Tab Right")) { if canMoveRight { onMoveRight() } } + .accessibilityAction(named: Text("Move Tab Left")) { if canMoveLeft { commands?.moveBy(tab.id, -1) } } + .accessibilityAction(named: Text("Move Tab Right")) { if canMoveRight { commands?.moveBy(tab.id, 1) } } } /// Everything the tab draws lives inside the glass, never over it. A `GlassEffectContainer` @@ -424,7 +276,7 @@ private struct EditorTabStripItem: View { /// label never receives the click. private var surface: some View { ZStack { - Button(action: onActivate) { title } + Button { commands?.activate(tab.id) } label: { title } .buttonStyle(.plain) HStack(spacing: 0) { @@ -484,6 +336,18 @@ private struct EditorTabStripItem: View { /// Carries the preview state, because the italic title cannot: an assistive technology is told /// the string, never the face it is set in, and the HIG asks that no interface rely on a single /// method to convey a change in state. + private var canKeepOpen: Bool { + commands?.canKeepOpen(tab.id) ?? false + } + + private var canMoveLeft: Bool { + commands?.canMove(tab.id, -1) ?? false + } + + private var canMoveRight: Bool { + commands?.canMove(tab.id, 1) ?? false + } + private var positionDescription: String { var description = String(format: String(localized: "%1$d of %2$d"), position, count) if tab.isPreview { @@ -518,7 +382,11 @@ private struct EditorTabStripItem: View { @ViewBuilder private var closeButton: some View { if isSelected || (isHovered && isWindowActive) { - EditorTabStripCloseButton(action: onClose, isWindowActive: isWindowActive) + EditorTabStripCloseButton( + action: { commands?.close(tab.id) }, + isHovering: isCloseHovered, + isWindowActive: isWindowActive + ) } else { Color.clear } @@ -527,10 +395,11 @@ private struct EditorTabStripItem: View { private struct EditorTabStripCloseButton: View { let action: () -> Void + /// Driven by the view that owns the pointer, because this button no longer receives a mouse + /// event of its own. + let isHovering: Bool let isWindowActive: Bool - @State private var isHovering = false - var body: some View { Button(action: action) { Image(systemName: "xmark") @@ -543,7 +412,6 @@ private struct EditorTabStripCloseButton: View { .contentShape(Circle()) } .buttonStyle(EditorTabStripCloseButtonStyle(isHovering: isHovering)) - .onHover { isHovering = $0 } .accessibilityLabel(Text("Close Tab")) } } @@ -594,13 +462,14 @@ private extension View { /// wash tints whatever is behind it, and the track has to stay below the selected tab whatever /// that happens to be. The system's own tab bar measures a track of rgb(232) in light against /// this fill's rgb(220), and rgb(71) in dark against this fill's rgb(70). - func trackSurface() -> some View { - background( - EditorTabStripLayout.trackShape + func trackSurface(rowCount: Int) -> some View { + let shape = EditorTabStripLayout.trackShape(forRowCount: rowCount) + return background( + shape .fill(EditorTabStripPalette.trackFill) .overlay( - EditorTabStripLayout.trackShape - .strokeBorder( + shape + .stroke( EditorTabStripPalette.trackEdge, lineWidth: EditorTabStripLayout.hairline ) diff --git a/TablePro/Views/Main/EditorTabStripAccessoryController.swift b/TablePro/Views/Main/EditorTabStripAccessoryController.swift index 89dcf7b64..b13c047ef 100644 --- a/TablePro/Views/Main/EditorTabStripAccessoryController.swift +++ b/TablePro/Views/Main/EditorTabStripAccessoryController.swift @@ -28,6 +28,15 @@ internal final class EditorTabStripAccessoryController: NSTitlebarAccessoryViewC /// same way the split items show its panes, so every other connection's strip stays built. private let paneHost = WorkspacePaneHost() + /// The band's height, which follows the strip. A wrapped strip is taller than a scrolling one, + /// and the accessory has to grow with it or the extra rows are drawn behind the content + /// instead of the window's `contentLayoutRect` making room for them. + /// + /// It is the view's own frame height, never a constraint. + /// `NSTitlebarAccessoryViewController` places its view itself and observes that height for + /// changes, which is what the width comment below already says. + private var bandHeight = EditorTabStripLayout.bandHeight + internal init() { super.init(nibName: nil, bundle: nil) layoutAttribute = .bottom @@ -60,6 +69,25 @@ internal final class EditorTabStripAccessoryController: NSTitlebarAccessoryViewC internal func show(_ controller: NSViewController?) { paneHost.show(controller) + applyBandHeight(paneHost.preferredContentSize.height) + } + + /// The strip publishes its height through `preferredContentSize`, the documented way for a + /// child to ask its parent for room, and `WorkspacePaneHost` passes it on from the pane it is + /// showing. Anything at or below zero means the pane has nothing to say yet, so the band keeps + /// the single-row height it was built with. + override internal func preferredContentSizeDidChange(for viewController: NSViewController) { + super.preferredContentSizeDidChange(for: viewController) + guard viewController === paneHost else { return } + applyBandHeight(viewController.preferredContentSize.height) + } + + private func applyBandHeight(_ height: CGFloat) { + let resolved = height > 0 ? height : EditorTabStripLayout.bandHeight + guard bandHeight != resolved else { return } + bandHeight = resolved + fullScreenMinHeight = resolved + view.frame.size.height = resolved } /// `hidden` collapses the band to zero height without taking it off the window, which is what diff --git a/TablePro/Views/Main/EditorTabStripInteraction.swift b/TablePro/Views/Main/EditorTabStripInteraction.swift new file mode 100644 index 000000000..0543cc506 --- /dev/null +++ b/TablePro/Views/Main/EditorTabStripInteraction.swift @@ -0,0 +1,195 @@ +// +// EditorTabStripInteraction.swift +// TablePro +// + +import AppKit +import Foundation +import Observation + +/// What a press on the strip turned out to be. +internal enum EditorTabGesture: Equatable { + case click(count: Int) + case reorder + case tearOff +} + +/// The one piece of state the strip's two halves share: AppKit owns the pointer and writes here, +/// SwiftUI reads it and draws. +/// +/// The split exists because the two jobs have different owners on macOS. Which control receives a +/// press inside a titlebar accessory was previously decided by SwiftUI gesture arbitration between +/// a `Button`, a `ScrollView` and a `DragGesture`, with AppKit's own window drag as a fourth +/// claimant, and the winner depended on timing: measured on this strip, a plain one-place drag did +/// nothing about one time in seven, and the four UI tests covering it have never passed CI. A +/// press has exactly one owner now, and it is `EditorTabInteractionView`. +/// +/// Geometry lives here rather than in the SwiftUI layout for the same reason. The tab a user sees, +/// the tab the pointer hits and the tab a drag targets are one rectangle out of +/// `EditorTabRunLayout`, so they cannot disagree. +@MainActor +@Observable +internal final class EditorTabStripInteraction { + /// The run the strip draws, measured by the view that owns the pointer. + internal private(set) var run: EditorTabRunLayout = .empty + /// How far the track has scrolled, in points. Always zero when the run wraps, because a + /// wrapped run never overflows. + internal private(set) var contentOffset: CGFloat = 0 + internal private(set) var hoveredTabId: UUID? + /// Set while the pointer is over a tab's close button, because the button no longer receives + /// the mouse and cannot light itself. + internal private(set) var hoveredCloseTabId: UUID? + /// The reorder in flight, holding both the order the strip draws and the order it came from. + /// The manager is not written until the pointer comes up, so an abandoned drag leaves nothing + /// behind and Escape is just dropping this value. + internal private(set) var reorder: EditorTabReorder? + internal private(set) var tearingOffTabId: UUID? + /// The track's visible width, measured by the view that owns the pointer. SwiftUI reads it so + /// a reveal and a clamp use the same number the pointer does. + internal private(set) var viewportWidth: CGFloat = 0 + /// The last width the pointer's owner measured, kept so a tab opening or closing can re-lay + /// the run without waiting for AppKit to schedule another layout pass. + private var trackWidth: CGFloat = 0 + + internal var overflow: EditorTabStripOverflow = .scroll { + didSet { + guard overflow != oldValue else { return } + contentOffset = 0 + updateRun(trackWidth: trackWidth, count: tabIds.count) + } + } + + /// Rebuilt on every render, because the closures reach through the workspace to a coordinator + /// that only exists once the detail pane has appeared. + internal var commands: EditorTabCommands? + + internal var tabIds: [UUID] = [] + + /// Raised whenever a rebuild changes how many rows the run takes, from any path. The band's + /// height follows it, and a tab opened or closed while the strip is wrapped can cross a row + /// boundary without any layout pass having run. + internal var onRowCountChanged: ((Int) -> Void)? + private var reportedRowCount = 1 + + /// The order the strip draws: the reorder's while one is in flight, the manager's otherwise. + internal var displayedIds: [UUID] { + reorder?.order ?? tabIds + } + + internal func updateRun(trackWidth: CGFloat, count: Int) { + self.trackWidth = trackWidth + viewportWidth = max(trackWidth - EditorTabStripLayout.trackPadding * 2, 0) + let rebuilt = EditorTabRunLayoutBuilder.run( + forTrack: trackWidth, + count: count, + overflow: overflow + ) + guard rebuilt != run else { return } + run = rebuilt + clampContentOffset() + guard run.rowCount != reportedRowCount else { return } + reportedRowCount = run.rowCount + onRowCountChanged?(run.rowCount) + } + + internal func setHovered(_ id: UUID?, overCloseButton: Bool = false) { + if hoveredTabId != id { hoveredTabId = id } + let close = overCloseButton ? id : nil + if hoveredCloseTabId != close { hoveredCloseTabId = close } + } + + internal func scroll(by delta: CGFloat) { + guard overflow == .scroll else { return } + contentOffset += delta + clampContentOffset() + } + + internal func clampContentOffset() { + let maximum = max(run.contentSize.width - viewportWidth, 0) + contentOffset = min(max(contentOffset, 0), maximum) + } + + /// Brings a tab fully into the viewport, which is what a selection made from the keyboard, the + /// sidebar or a closed neighbour needs. + internal func revealTab(id: UUID) { + guard overflow == .scroll, viewportWidth > 0 else { return } + guard let index = displayedIds.firstIndex(of: id), let placement = run.placement(at: index) else { return } + if placement.frame.minX < contentOffset { + contentOffset = placement.frame.minX + } else if placement.frame.maxX > contentOffset + viewportWidth { + contentOffset = placement.frame.maxX - viewportWidth + } + clampContentOffset() + } + + internal func beginReorder(of id: UUID) { + guard reorder == nil else { return } + reorder = EditorTabReorder(draggedId: id, order: tabIds) + } + + /// Moves the dragged tab as the pointer passes each neighbour's centre. Returns true when the + /// order changed, so the caller can animate only then. + @discardableResult + internal func updateReorder(toLinearLocation location: CGFloat) -> Bool { + guard var live = reorder, let currentIndex = live.destinationIndex else { return false } + let destination = EditorTabReorderResolver.settledDestination( + forLocation: location, + tabWidth: run.tabWidth, + currentIndex: currentIndex, + count: live.order.count + ) + guard let destination else { return false } + live.move(to: destination) + reorder = live + return true + } + + internal func markTearingOff(_ id: UUID?) { + guard tearingOffTabId != id else { return } + tearingOffTabId = id + } + + /// Writes the order to the manager once, on release, or not at all when nothing moved. + internal func commitReorder() { + defer { clearReorder() } + guard let reorder, reorder.movedFromOriginal, let destination = reorder.destinationIndex else { return } + commands?.moveTab(reorder.draggedId, destination) + } + + internal func clearReorder() { + reorder = nil + tearingOffTabId = nil + } + + /// 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]) { + tabIds = ids + updateRun(trackWidth: trackWidth, count: ids.count) + guard var live = reorder else { return } + guard live.removingClosedTabs(keeping: ids), Set(live.order) == Set(ids) else { + clearReorder() + return + } + reorder = live + } +} + +/// Everything the strip can ask the app to do, in one place, so the AppKit view that owns the +/// pointer never reaches into a view model of its own. +internal struct EditorTabCommands { + internal let activate: (UUID) -> Void + internal let keepOpen: (UUID) -> Void + internal let canKeepOpen: (UUID) -> Bool + internal let close: (UUID) -> Void + internal let closeOthers: (UUID) -> Void + internal let closeAll: () -> Void + internal let moveTab: (UUID, Int) -> Void + internal let canMove: (UUID, Int) -> Bool + internal let moveBy: (UUID, Int) -> Void + internal let tearOff: (UUID) -> Void + internal let canTearOff: (UUID) -> Bool + /// The pointer's owner sets the view's tooltip from this, because the tab's own `.help` never + /// sees a mouse now. + internal let tooltip: (UUID) -> String +} diff --git a/TablePro/Views/Main/EditorTabStripLayout.swift b/TablePro/Views/Main/EditorTabStripLayout.swift index 7a7a4cecc..7cccf50ca 100644 --- a/TablePro/Views/Main/EditorTabStripLayout.swift +++ b/TablePro/Views/Main/EditorTabStripLayout.swift @@ -36,6 +36,30 @@ internal enum EditorTabStripLayout { internal static var bandBottomClearance: CGFloat { bandHeight - trackHeight } + /// The gap between two wrapped rows, and the pitch that follows from it. + internal static let rowSpacing: CGFloat = 2 + internal static var rowStride: CGFloat { tabHeight + rowSpacing } + + /// A wrapped strip grows the track and the band with it, so the content below is laid out + /// around the taller band rather than behind it. One row resolves to the measured numbers. + internal static func trackHeight(forRowCount rows: Int) -> CGFloat { + trackHeight + CGFloat(max(rows, 1) - 1) * rowStride + } + + internal static func bandHeight(forRowCount rows: Int) -> CGFloat { + bandHeight + CGFloat(max(rows, 1) - 1) * rowStride + } + + /// How close to the viewport's edge the pointer has to come before a reorder starts scrolling + /// the track under it. One tab's minimum width would swallow the whole viewport on a narrow + /// window, so this is a fixed band, the same shape `NSView.autoscroll(with:)` applies. + internal static let autoscrollMargin: CGFloat = 24 + internal static let autoscrollStep: CGFloat = 12 + + /// How far the pointer has to leave the strip before a reorder becomes a tear-off. Measured + /// against the band rather than the tab, so a drag that stays in the chrome is still a reorder. + internal static let tearOffThreshold: CGFloat = 44 + /// Fully rounded, because that is what the system's own tab bar is: the runtime /// probe reports `cornerRadius = 12` on each 24pt `NSGlassEffectView` tab, which is exactly /// half its height, and a corner fit of the 28pt track lands at 12 to 14pt. An in-content @@ -44,6 +68,16 @@ internal enum EditorTabStripLayout { internal static var trackShape: Capsule { Capsule(style: .continuous) } internal static var tabShape: Capsule { Capsule(style: .continuous) } + /// A capsule is only the system's shape while the track is one row tall. Its radius is half + /// the height, so a wrapped track would round away most of the first and last rows rather + /// than its ends, and the close button drawn in that corner would disappear under the curve + /// while the pointer still hit-tests the full rectangle. Past one row the corner holds at the + /// radius a single row would have had. + internal static func trackShape(forRowCount rows: Int) -> AnyShape { + guard rows > 1 else { return AnyShape(trackShape) } + return AnyShape(RoundedRectangle(cornerRadius: trackHeight / 2, style: .continuous)) + } + /// Tabs share the track equally, and stop shrinking at a width that still fits a name so a /// long list scrolls instead of collapsing into slivers. The system staggers widths slightly /// by an undocumented rule; an equal share is within a couple of points of it. @@ -57,6 +91,10 @@ internal enum EditorTabStripLayout { /// strip, not so far that its title stops being legible on the way past its neighbours. internal static let draggingOpacity: CGFloat = 0.45 + /// A tab being torn off fades further than one being reordered, so the gesture says which of + /// the two it is before the pointer comes up. + internal static let tearingOffOpacity: CGFloat = 0.2 + /// How far the pointer travels before a press on a tab becomes a reorder rather than a click. /// The same distance AppKit uses to tell a click from a drag, so a hand that shifts a point or /// two while clicking still selects the tab. diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 6d5fe8623..003e98f1f 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -49,6 +49,13 @@ struct GeneralSettingsView: View { Section("Tabs") { Toggle("Enable preview tabs", isOn: $tabSettings.enablePreviewTabs) .help("Single-clicking a table opens a temporary tab that gets replaced on next click.") + + Picker("When tabs stop fitting:", selection: $tabSettings.overflow) { + ForEach(EditorTabStripOverflow.allCases, id: \.self) { style in + Text(style.displayName).tag(style) + } + } + .help("Scrolling keeps one row of tabs, the way every macOS tab bar does. Rows wraps them so nothing is off screen.") } Section("Sidebar") { diff --git a/TableProTests/Models/EditorTabReorderTests.swift b/TableProTests/Models/EditorTabReorderTests.swift index 9bc2b0ab4..1218ce072 100644 --- a/TableProTests/Models/EditorTabReorderTests.swift +++ b/TableProTests/Models/EditorTabReorderTests.swift @@ -220,3 +220,57 @@ struct EditorTabReorderResolverTests { #expect(destination == 12) } } + +/// The boundary the commonest drag of all lands on. +@Suite("Editor tab reorder crossing tolerance") +struct EditorTabReorderCrossingToleranceTests { + /// Releasing on a neighbour's exact centre is what a one-place drag does, and the location + /// arrives from a geometry conversion, so it is a hair under the midpoint as often as it is on + /// it. Comparing for equality made the move a coin flip; measured on the shipping strip, two + /// of thirteen plain drags did nothing. + @Test("A release a hair short of the midpoint still crosses it") + func aHairShortOfTheMidpointCrosses() { + let width: CGFloat = 244 + let midpoint = 2.5 * width + + #expect( + EditorTabReorderResolver.settledDestination( + forLocation: midpoint - 0.0001, + tabWidth: width, + currentIndex: 3, + count: 6 + ) == 2 + ) + } + + @Test("A release a hair past the midpoint crosses it going the other way") + func aHairPastTheMidpointCrosses() { + let width: CGFloat = 244 + let midpoint = 3.5 * width + + #expect( + EditorTabReorderResolver.settledDestination( + forLocation: midpoint + 0.0001, + tabWidth: width, + currentIndex: 2, + count: 6 + ) == 3 + ) + } + + /// The tolerance is half a point, which is below anything a hand or an eye can aim at. A tab + /// still has to be genuinely crossed for the order to change. + @Test("A release well short of the midpoint does not cross it") + func wellShortOfTheMidpointDoesNotCross() { + let width: CGFloat = 244 + + #expect( + EditorTabReorderResolver.settledDestination( + forLocation: 2.5 * width + 4, + tabWidth: width, + currentIndex: 3, + count: 6 + ) == nil + ) + } +} diff --git a/TableProTests/Views/Main/EditorTabRunLayoutTests.swift b/TableProTests/Views/Main/EditorTabRunLayoutTests.swift new file mode 100644 index 000000000..d4de4acc1 --- /dev/null +++ b/TableProTests/Views/Main/EditorTabRunLayoutTests.swift @@ -0,0 +1,133 @@ +// +// EditorTabRunLayoutTests.swift +// TableProTests +// + +import CoreGraphics +import Foundation +import Testing + +@testable import TablePro + +@Suite("Editor tab run layout") +struct EditorTabRunLayoutTests { + private static let trackWidth: CGFloat = 604 + + @Test("Tabs that fit share the track in one row") + func fittingTabsShareOneRow() { + let run = EditorTabRunLayoutBuilder.run(forTrack: Self.trackWidth, count: 3, overflow: .scroll) + + #expect(run.rowCount == 1) + #expect(run.placements.count == 3) + #expect(run.tabWidth == 200) + #expect(run.placements.map(\.frame.minX) == [0, 200, 400]) + #expect(run.placements.allSatisfy { $0.row == 0 }) + } + + @Test("Scrolling keeps one row and lets the content run past the track") + func scrollingKeepsOneRow() { + let run = EditorTabRunLayoutBuilder.run(forTrack: Self.trackWidth, count: 12, overflow: .scroll) + + #expect(run.rowCount == 1) + #expect(run.tabWidth == EditorTabStripLayout.minimumTabWidth) + #expect(run.contentSize.width == EditorTabStripLayout.minimumTabWidth * 12) + #expect(run.contentSize.width > Self.trackWidth) + } + + /// The whole point of the wrapped run: nothing is off screen, so a tab opened first is still + /// reachable however many followed it. + @Test("Wrapping never runs wider than the track") + func wrappingNeverOverflows() { + let run = EditorTabRunLayoutBuilder.run(forTrack: Self.trackWidth, count: 12, overflow: .rows) + + #expect(run.tabsPerRow == 5) + #expect(run.rowCount == 3) + #expect(run.contentSize.width <= Self.trackWidth) + #expect(run.placements.map(\.row) == [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2]) + } + + @Test("A wrapped run stacks its rows by the row stride") + func wrappedRowsStack() { + let run = EditorTabRunLayoutBuilder.run(forTrack: Self.trackWidth, count: 7, overflow: .rows) + let second = run.placement(at: 5) + + #expect(second?.row == 1) + #expect(second?.frame.minY == EditorTabStripLayout.rowStride) + #expect(second?.frame.minX == 0) + } + + /// A narrow window still shows one tab per row rather than dividing by zero. + @Test("A track narrower than one tab keeps a single column") + func narrowTrackKeepsOneColumn() { + let run = EditorTabRunLayoutBuilder.run(forTrack: 40, count: 4, overflow: .rows) + + #expect(run.tabsPerRow == 1) + #expect(run.rowCount == 4) + } + + @Test("An empty run is empty rather than a single zero-width tab") + func emptyRun() { + #expect(EditorTabRunLayoutBuilder.run(forTrack: Self.trackWidth, count: 0, overflow: .scroll) == .empty) + #expect(EditorTabRunLayoutBuilder.run(forTrack: 0, count: 4, overflow: .scroll) == .empty) + } + + @Test("A point inside a tab resolves to it, and the gap after a wrapped last row to nothing") + func hitTesting() { + let run = EditorTabRunLayoutBuilder.run(forTrack: Self.trackWidth, count: 6, overflow: .rows) + + #expect(EditorTabRunLayoutBuilder.index(at: CGPoint(x: 10, y: 10), in: run) == 0) + #expect(EditorTabRunLayoutBuilder.index(at: CGPoint(x: 130, y: 10), in: run) == 1) + #expect( + EditorTabRunLayoutBuilder.index( + at: CGPoint(x: 400, y: EditorTabStripLayout.rowStride + 10), + in: run + ) == nil + ) + } + + @Test("The close button sits in the tab's leading accessory slot") + func closeButtonRect() throws { + let run = EditorTabRunLayoutBuilder.run(forTrack: Self.trackWidth, count: 3, overflow: .scroll) + let placement = try #require(run.placement(at: 1)) + let rect = EditorTabRunLayoutBuilder.closeButtonRect(in: placement.frame) + + #expect(rect.minX == 200 + EditorTabStripLayout.accessoryInset) + #expect(rect.width == EditorTabStripLayout.accessoryWidth) + #expect(rect.midY == EditorTabStripLayout.tabHeight / 2) + } + + /// One row is already the axis the reorder resolves along, so it is passed through untouched. + @Test("A single row projects onto its own x") + func singleRowLinearLocation() { + let run = EditorTabRunLayoutBuilder.run(forTrack: Self.trackWidth, count: 3, overflow: .scroll) + + #expect(EditorTabRunLayoutBuilder.linearLocation(of: CGPoint(x: 250, y: 5), in: run) == 250) + } + + /// A wrapped run is projected onto the run it would have been unwrapped, so the reorder keeps + /// one rule with its midpoint hysteresis rather than growing a second one for rows. + @Test("A wrapped row contributes a whole row of tabs to the location") + func wrappedLinearLocation() { + let run = EditorTabRunLayoutBuilder.run(forTrack: Self.trackWidth, count: 12, overflow: .rows) + let secondRow = CGPoint(x: 30, y: EditorTabStripLayout.rowStride + 5) + + #expect( + EditorTabRunLayoutBuilder.linearLocation(of: secondRow, in: run) + == run.tabWidth * CGFloat(run.tabsPerRow) + 30 + ) + } + + /// A drag that leaves the strip above or below still resolves against the nearest row rather + /// than jumping to the start of the run. + @Test("A point above or below the run clamps to the first and last rows") + func clampedRows() { + let run = EditorTabRunLayoutBuilder.run(forTrack: Self.trackWidth, count: 12, overflow: .rows) + let rowWidth = run.tabWidth * CGFloat(run.tabsPerRow) + + #expect(EditorTabRunLayoutBuilder.linearLocation(of: CGPoint(x: 10, y: -200), in: run) == 10) + #expect( + EditorTabRunLayoutBuilder.linearLocation(of: CGPoint(x: 10, y: 5_000), in: run) + == rowWidth * 2 + 10 + ) + } +} diff --git a/TableProTests/Views/Main/EditorTabStripChromeTests.swift b/TableProTests/Views/Main/EditorTabStripChromeTests.swift index 523c35862..bf5810634 100644 --- a/TableProTests/Views/Main/EditorTabStripChromeTests.swift +++ b/TableProTests/Views/Main/EditorTabStripChromeTests.swift @@ -89,12 +89,16 @@ struct EditorTabStripChromeTests { manager.tabs = ["Album", "Artist", "Customer"].map { QueryTab(title: $0) } manager.selectedTabId = manager.tabs.first?.id + /// AppKit measures the run in the app, so the harness seeds it: this rasterises the strip + /// without the view that owns the pointer, and the tabs have to know where they are. + let interaction = EditorTabStripInteraction() + interaction.dropClosedTabs(keeping: manager.tabs.map(\.id)) + interaction.updateRun(trackWidth: Self.trackWidth, count: manager.tabs.count) + let strip = EditorTabStrip( tabManager: manager, + interaction: interaction, containerTarget: nil, - onClose: { _ in }, - onCloseOthers: { _ in }, - onCloseAll: {}, onNewTab: {}, surfaceStyle: .solid ) diff --git a/TableProTests/Views/Main/EditorTabStripInteractionTests.swift b/TableProTests/Views/Main/EditorTabStripInteractionTests.swift new file mode 100644 index 000000000..98d328344 --- /dev/null +++ b/TableProTests/Views/Main/EditorTabStripInteractionTests.swift @@ -0,0 +1,164 @@ +// +// EditorTabStripInteractionTests.swift +// TableProTests +// + +import CoreGraphics +import Foundation +import Testing + +@testable import TablePro + +@MainActor +@Suite("Editor tab strip interaction") +struct EditorTabStripInteractionTests { + private static let trackWidth: CGFloat = 604 + + /// Records what the strip asked the app to do, so a reorder can be checked for reaching the + /// tab manager exactly once and only on release. + private final class Recorder { + var moves: [(UUID, Int)] = [] + var activated: [UUID] = [] + var closed: [UUID] = [] + } + + private func makeInteraction( + tabCount: Int, + overflow: EditorTabStripOverflow = .scroll, + recorder: Recorder = Recorder() + ) -> (EditorTabStripInteraction, [UUID], Recorder) { + let ids = (0 ..< tabCount).map { _ in UUID() } + let interaction = EditorTabStripInteraction() + interaction.overflow = overflow + interaction.commands = EditorTabCommands( + activate: { recorder.activated.append($0) }, + keepOpen: { _ in }, + canKeepOpen: { _ in false }, + close: { recorder.closed.append($0) }, + closeOthers: { _ in }, + closeAll: {}, + moveTab: { recorder.moves.append(($0, $1)) }, + canMove: { _, _ in true }, + moveBy: { _, _ in }, + tearOff: { _ in }, + canTearOff: { _ in false }, + tooltip: { _ in "" } + ) + interaction.dropClosedTabs(keeping: ids) + interaction.updateRun(trackWidth: Self.trackWidth, count: tabCount) + return (interaction, ids, recorder) + } + + @Test("A reorder draws from its own order and leaves the manager alone until release") + func reorderIsNotCommittedUntilRelease() { + let (interaction, ids, recorder) = makeInteraction(tabCount: 4) + + interaction.beginReorder(of: ids[0]) + interaction.updateReorder(toLinearLocation: interaction.run.tabWidth * 2.5) + + #expect(interaction.displayedIds != ids) + #expect(recorder.moves.isEmpty) + + interaction.commitReorder() + + #expect(recorder.moves.count == 1) + #expect(recorder.moves.first?.0 == ids[0]) + #expect(recorder.moves.first?.1 == 2) + #expect(interaction.reorder == nil) + } + + /// Escape drops the value the drag was building, and the manager was never written, so there + /// is nothing to undo. + @Test("A cancelled reorder writes nothing and puts the order back") + func cancelledReorderWritesNothing() { + let (interaction, ids, recorder) = makeInteraction(tabCount: 4) + + interaction.beginReorder(of: ids[0]) + interaction.updateReorder(toLinearLocation: interaction.run.tabWidth * 2.5) + interaction.clearReorder() + + #expect(recorder.moves.isEmpty) + #expect(interaction.displayedIds == ids) + } + + @Test("A reorder that ends where it started writes nothing") + func unmovedReorderWritesNothing() { + let (interaction, ids, recorder) = makeInteraction(tabCount: 4) + + interaction.beginReorder(of: ids[1]) + interaction.commitReorder() + + #expect(recorder.moves.isEmpty) + } + + /// The pointer and the keyboard are independent streams, so `Cmd+W` can close the tab that is + /// mid-drag. A stale id must not reach the commit and put back a tab that is gone. + @Test("Closing the dragged tab ends the drag") + func closingTheDraggedTabEndsTheDrag() { + let (interaction, ids, recorder) = makeInteraction(tabCount: 4) + + interaction.beginReorder(of: ids[0]) + interaction.dropClosedTabs(keeping: Array(ids.dropFirst())) + + #expect(interaction.reorder == nil) + interaction.commitReorder() + #expect(recorder.moves.isEmpty) + } + + @Test("Closing another tab keeps the drag alive without it") + func closingAnotherTabKeepsTheDrag() { + let (interaction, ids, _) = makeInteraction(tabCount: 4) + + interaction.beginReorder(of: ids[0]) + interaction.dropClosedTabs(keeping: [ids[0], ids[1], ids[3]]) + + #expect(interaction.reorder != nil) + #expect(interaction.displayedIds.count == 3) + } + + @Test("The track scrolls only as far as the content runs") + func scrollingClampsToTheContent() { + let (interaction, _, _) = makeInteraction(tabCount: 12) + let maximum = interaction.run.contentSize.width - interaction.viewportWidth + + interaction.scroll(by: 10_000) + #expect(interaction.contentOffset == maximum) + + interaction.scroll(by: -10_000) + #expect(interaction.contentOffset == 0) + } + + /// A wrapped run never overflows, so there is nothing to scroll and the wheel is inert. + @Test("A wrapped run does not scroll") + func wrappedRunDoesNotScroll() { + let (interaction, _, _) = makeInteraction(tabCount: 12, overflow: .rows) + + interaction.scroll(by: 500) + + #expect(interaction.contentOffset == 0) + } + + @Test("Revealing a tab scrolls it fully into the viewport from either side") + func revealScrollsFromEitherSide() { + let (interaction, ids, _) = makeInteraction(tabCount: 12) + + interaction.revealTab(id: ids[11]) + let placement = interaction.run.placement(at: 11) + #expect(interaction.contentOffset == (placement?.frame.maxX ?? 0) - interaction.viewportWidth) + + interaction.revealTab(id: ids[0]) + #expect(interaction.contentOffset == 0) + } + + @Test("Switching to rows drops the scroll offset the run no longer has") + func switchingToRowsResetsTheOffset() { + let (interaction, _, _) = makeInteraction(tabCount: 12) + interaction.scroll(by: 400) + #expect(interaction.contentOffset > 0) + + interaction.overflow = .rows + + #expect(interaction.contentOffset == 0) + #expect(interaction.run.rowCount > 1) + } +} diff --git a/TableProUITests/EditorTabReorderUITests.swift b/TableProUITests/EditorTabReorderUITests.swift index d2fdf3e6f..ef4ab5cb9 100644 --- a/TableProUITests/EditorTabReorderUITests.swift +++ b/TableProUITests/EditorTabReorderUITests.swift @@ -1,13 +1,12 @@ import AppKit import XCTest -/// Reordering by drag, after it stopped being a drag-and-drop session and became direct -/// manipulation. These cover the half of the strip that is fixed: a drag reorders, and it reorders -/// the same whether the tab is selected, unselected, or in a strip long enough to scroll. +/// Reordering by drag, now that AppKit owns the press rather than arbitrating for it. /// -/// They deliberately do not assert the window's origin. A press inside the leading region of the -/// titlebar still drags the window rather than the tab, which is the other half of #2438 and is -/// not fixed here. +/// The window's origin is asserted here. The strip lives in a titlebar accessory, so AppKit's own +/// window drag is a claimant on every press that lands on a tab, and `EditorTabInteractionView` +/// answers `false` to `mouseDownCanMoveWindow` precisely so it can never win. A test that only +/// checks the order would pass just as happily with the window sliding across the screen. final class EditorTabReorderUITests: UITestCase { func testDraggingATabReordersTheStrip() throws { let app = try launchWithSampleDatabase() @@ -169,12 +168,12 @@ final class EditorTabReorderUITests: UITestCase { /// compiles here and drives nothing: measured at HEAD, it left the tab order unchanged even on /// the selected tab, which real events do reorder, so it was reading the harness rather than /// the strip. - private func drag(_ source: XCUIElement, onto destination: XCUIElement) { + private func drag(_ source: XCUIElement, onto destination: XCUIElement, hold: TimeInterval = 0.6) { XCTAssertTrue(waitUntilHittable(source, timeout: 20), "The dragged tab must be hittable") XCTAssertTrue(waitUntilHittable(destination, timeout: 20), "The drop target tab must be hittable") source.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) .click( - forDuration: 0.6, + forDuration: hold, thenDragTo: destination.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) ) } @@ -188,6 +187,58 @@ final class EditorTabReorderUITests: UITestCase { waitForPredicate(timeout: 15) { self.tabLabels(in: window) != before } } + /// The guard the strip's home makes necessary. A press on a tab inside a titlebar accessory is + /// one AppKit would otherwise turn into a window drag, and this is the assertion that says it + /// does not: the reported symptom of #2438 was the whole window travelling with the pointer. + func testDraggingATabNeverMovesTheWindow() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + + openTables(["Album", "Artist", "Customer"], in: window) + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.tabLabels(in: window).count >= 3 }, + "The strip must show a tab per open table, got \(tabLabels(in: window))" + ) + + let before = tabLabels(in: window) + let origin = window.frame.origin + + drag(tab(named: before[0], in: window), onto: tab(named: before[2], in: window), hold: 0.02) + + XCTAssertTrue( + waitForTabOrder(toChangeFrom: before, in: window), + "Dragging a tab must change the tab order, was \(before)" + ) + XCTAssertEqual( + window.frame.origin, + origin, + "Dragging a tab must move the tab, never the window" + ) + } + + /// A user presses and drags in one movement. The suite's other cases hold for 0.6s first, + /// which is not a gesture anybody makes, and the arbitration this replaces behaved differently + /// under the two. + func testAFastDragReordersTheStrip() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + + openTables(["Album", "Artist", "Customer"], in: window) + XCTAssertTrue( + waitForPredicate(timeout: 20) { self.tabLabels(in: window).count >= 3 }, + "The strip must show a tab per open table, got \(tabLabels(in: window))" + ) + + let before = tabLabels(in: window) + + drag(tab(named: before[0], in: window), onto: tab(named: before[1], in: window), hold: 0.02) + + XCTAssertTrue( + waitForTabOrder(toChangeFrom: before, in: window), + "A drag with no hold before it must reorder, was \(before)" + ) + } + /// The tabs the pointer can actually reach. /// /// Once the strip overflows, the track scrolls and the tabs outside the viewport stay in the diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index a54f129fd..159bb94d6 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -33,6 +33,7 @@ What a fresh install ships with, so one glance down this column says what you ch | General | Language | System | | General | When TablePro starts | Reopen Last Session | | General | Enable preview tabs | On | +| General | When tabs stop fitting | Scroll them | | General | Show connections | On | | General | Show recent tables | Off | | General | Show object icons | On | diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index 416e0472a..4b8425769 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -77,12 +77,20 @@ Pending cell edits are the one thing that does not come back: they are never wri Drag a tab along the strip to move it; the others slide out of the way as it passes their middle. Press `Esc` before letting go to put it back where it started. The new order is saved when you release, so a drag you abandon leaves nothing behind. +Hold the drag near either end of the strip and the track scrolls under it, so a tab reaches a place that is not currently on screen. + Right-click a tab for **Move Tab Left** and **Move Tab Right**, which move it one place at a time, dim at the ends of the strip, and are offered to VoiceOver as actions on the tab. Tabs cannot be pinned. Pinning exists for result tabs inside a query tab (`Cmd+Option+P`). +## Tabs that stop fitting + +The strip keeps one row and scrolls it, the way every macOS tab bar does. Scroll over it with two fingers to reach the rest. + +**Settings > General > Tabs > When tabs stop fitting** switches to **Wrap onto more rows**, which lays the tabs out over as many rows as they need and grows the strip to match. Nothing is off screen, and the window has that much less room for the table below. + ## Switching tabs `Cmd+1` through `Cmd+9` jump by position, `Cmd+Shift+[` and `Cmd+Shift+]` step, and **Window > Show Previous Tab** and **Window > Show Next Tab** do the same from the menu bar. A tab scrolled out of sight is pulled back into view. Switching keeps SQL, cursor, results, scroll position, sort, filters, and pending changes.