diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 7d9433ff..fced6344 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -5733,7 +5733,7 @@ private struct SidebarExternalDropOverlay: View { .contentShape(Rectangle()) .allowsHitTesting(true) .onDrop( - of: [SidebarTabDragPayload.typeIdentifier], + of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarExternalDropDelegate(draggedTabId: draggedTabId) ) } else { @@ -6068,7 +6068,7 @@ private struct SidebarEmptyArea: View { } selection = .tabs } - .onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate( + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( targetTabId: nil, tabManager: tabManager, draggedTabId: $draggedTabId, @@ -6118,6 +6118,59 @@ enum SidebarPathFormatter { } } +enum SidebarWorkspaceShortcutHintMetrics { + private static let measurementFont = NSFont.systemFont(ofSize: 10, weight: .semibold) + private static let minimumSlotWidth: CGFloat = 28 + private static let horizontalPadding: CGFloat = 12 + private static let lock = NSLock() + private static var cachedHintWidths: [String: CGFloat] = [:] + #if DEBUG + private static var measurementCount = 0 + #endif + + static func slotWidth(label: String?, debugXOffset: Double) -> CGFloat { + guard let label else { return minimumSlotWidth } + let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(debugXOffset))) + 2 + return max(minimumSlotWidth, hintWidth(for: label) + positiveDebugInset) + } + + static func hintWidth(for label: String) -> CGFloat { + lock.lock() + if let cached = cachedHintWidths[label] { + lock.unlock() + return cached + } + lock.unlock() + + let textWidth = (label as NSString).size(withAttributes: [.font: measurementFont]).width + let measuredWidth = ceil(textWidth) + horizontalPadding + + lock.lock() + cachedHintWidths[label] = measuredWidth + #if DEBUG + measurementCount += 1 + #endif + lock.unlock() + return measuredWidth + } + + #if DEBUG + static func resetCacheForTesting() { + lock.lock() + cachedHintWidths.removeAll() + measurementCount = 0 + lock.unlock() + } + + static func measurementCountForTesting() -> Int { + lock.lock() + let count = measurementCount + lock.unlock() + return count + } + #endif +} + private struct TabItemView: View { @EnvironmentObject var tabManager: TabManager @EnvironmentObject var notificationStore: TerminalNotificationStore @@ -6244,15 +6297,10 @@ private struct TabItemView: View { } private var workspaceHintSlotWidth: CGFloat { - guard let label = workspaceShortcutLabel else { return 28 } - let positiveDebugInset = max(0, CGFloat(ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset))) + 2 - return max(28, workspaceHintWidth(for: label) + positiveDebugInset) - } - - private func workspaceHintWidth(for label: String) -> CGFloat { - let font = NSFont.systemFont(ofSize: 10, weight: .semibold) - let textWidth = (label as NSString).size(withAttributes: [.font: font]).width - return ceil(textWidth) + 12 + SidebarWorkspaceShortcutHintMetrics.slotWidth( + label: workspaceShortcutLabel, + debugXOffset: sidebarShortcutHintXOffset + ) } var body: some View { @@ -6550,7 +6598,7 @@ private struct TabItemView: View { dropIndicator = nil return SidebarTabDragPayload.provider(for: tab.id) } - .onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate( + .onDrop(of: SidebarTabDragPayload.dropContentTypes, delegate: SidebarTabDropDelegate( targetTabId: tab.id, tabManager: tabManager, draggedTabId: $draggedTabId, @@ -6560,7 +6608,7 @@ private struct TabItemView: View { dragAutoScrollController: dragAutoScrollController, dropIndicator: $dropIndicator )) - .onDrop(of: [BonsplitTabDragPayload.typeIdentifier], delegate: SidebarBonsplitTabDropDelegate( + .onDrop(of: BonsplitTabDragPayload.dropContentTypes, delegate: SidebarBonsplitTabDropDelegate( targetWorkspaceId: tab.id, tabManager: tabManager, selectedTabIds: $selectedTabIds, @@ -7789,6 +7837,8 @@ private final class SidebarDragAutoScrollController: ObservableObject { private enum SidebarTabDragPayload { static let typeIdentifier = "com.cmux.sidebar-tab-reorder" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] private static let prefix = "cmux.sidebar-tab." static func provider(for tabId: UUID) -> NSItemProvider { @@ -7804,6 +7854,8 @@ private enum SidebarTabDragPayload { private enum BonsplitTabDragPayload { static let typeIdentifier = "com.splittabbar.tabtransfer" + static let dropContentType = UTType(exportedAs: typeIdentifier) + static let dropContentTypes: [UTType] = [dropContentType] private static let currentProcessId = Int32(ProcessInfo.processInfo.processIdentifier) struct Transfer: Decodable { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 86ef7453..984112a4 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -6081,6 +6081,45 @@ final class WindowDragHandleHitTests: XCTestCase { } } +#if DEBUG +@MainActor +final class SidebarWorkspaceShortcutHintMetricsTests: XCTestCase { + override func setUp() { + super.setUp() + SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting() + } + + override func tearDown() { + SidebarWorkspaceShortcutHintMetrics.resetCacheForTesting() + super.tearDown() + } + + func testHintWidthCachesRepeatedMeasurements() { + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 0) + + let first = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1") + XCTAssertGreaterThan(first, 0) + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1) + + let second = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘1") + XCTAssertEqual(second, first) + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 1) + + _ = SidebarWorkspaceShortcutHintMetrics.hintWidth(for: "⌘2") + XCTAssertEqual(SidebarWorkspaceShortcutHintMetrics.measurementCountForTesting(), 2) + } + + func testSlotWidthAppliesMinimumAndDebugInset() { + let nilLabelWidth = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: nil, debugXOffset: 999) + XCTAssertEqual(nilLabelWidth, 28) + + let base = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 0) + let widened = SidebarWorkspaceShortcutHintMetrics.slotWidth(label: "⌘1", debugXOffset: 10) + XCTAssertGreaterThan(widened, base) + } +} +#endif + @MainActor final class DraggableFolderHitTests: XCTestCase { func testFolderHitTestReturnsContainerWhenInsideBounds() {