diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/RightSidebarModeBarDragLayout.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/RightSidebarModeBarDragLayout.swift index f4563b2a6f47..cc1b3a0483b1 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/RightSidebarModeBarDragLayout.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/RightSidebarModeBarDragLayout.swift @@ -72,11 +72,14 @@ public struct RightSidebarModeBarDragLayout: Equatable { return result } - /// True once the pointer is far enough above or below the bar that the - /// tab is being carried out of it, toward a pane. - public static func leavesBar(pointerY: CGFloat, tabFrame: CGRect, barHeight: CGFloat) -> Bool { + /// True once the pointer is outside the mode bar, toward a pane. The main + /// workspace is to the left of the right sidebar, so crossing x = 0 must + /// hand off just like leaving vertically does. + public static func leavesBar(pointer: CGPoint, tabFrame: CGRect, barHeight: CGFloat) -> Bool { let margin = max(barHeight, tabFrame.height) / 2 - return pointerY < tabFrame.minY - margin || pointerY > tabFrame.maxY + margin + return pointer.x < 0 + || pointer.y < tabFrame.minY - margin + || pointer.y > tabFrame.maxY + margin } private func heldOffset(_ translation: CGFloat) -> CGFloat { diff --git a/Sources/Cloud/CloudTreeMachineDetailLayout.swift b/Sources/Cloud/CloudTreeMachineDetailLayout.swift index 56b3d4b41d88..ed1081a36820 100644 --- a/Sources/Cloud/CloudTreeMachineDetailLayout.swift +++ b/Sources/Cloud/CloudTreeMachineDetailLayout.swift @@ -81,6 +81,12 @@ struct CloudTreeMachineDetailLayout { } } node.children.forEach(collect) + // While the machine connects, Ports, Terminals and Displays have + // nothing current to show, so only Resources (fleet telemetry, which + // does not need the link) keeps its tab. + if rows.contains(where: Self.isConnectingPlaceholder) { + for tab in [CloudTreeMachineDetailTab.ports, .terminals, .displays] { pools[tab] = nil } + } let tabs = CloudTreeMachineDetailTab.allCases.filter { pools[$0] != nil } if !tabs.isEmpty { let row = tabRow(machine: machine, tabs: tabs, pools: pools) @@ -162,5 +168,10 @@ struct CloudTreeMachineDetailLayout { return CloudTreeNode(id: node.id, kind: .terminal(row), children: node.children, isPinned: node.isPinned) } + private static func isConnectingPlaceholder(_ node: CloudTreeNode) -> Bool { + if case .placeholder(_, let placeholder) = node.kind { return placeholder.style == .connecting } + return false + } + private static func baseID(_ machine: SurfaceMachineID) -> String { "machine:\(machine.rawValue)" } } diff --git a/Sources/RightSidebarChromeStyle.swift b/Sources/RightSidebarChromeStyle.swift index 2a29f64d9d86..a5440bf1b120 100644 --- a/Sources/RightSidebarChromeStyle.swift +++ b/Sources/RightSidebarChromeStyle.swift @@ -361,6 +361,9 @@ struct RightSidebarModeBarItem: Identifiable, Equatable, Sendable { struct ModeBarButton: View { let item: RightSidebarModeBarItem let isSelected: Bool + /// The tab is actively being dragged. Its icon stays anchored while the + /// full label slot opens around it. + var isDragged = false var badgeCount: Int = 0 let shortcutHint: StoredShortcut let showsShortcutHint: Bool @@ -383,7 +386,10 @@ struct ModeBarButton: View { /// With its label hidden, the icon (and badge) moves into the middle of /// the label's empty slot, so it sits centered in the tab's highlight. private var hiddenLabelShift: CGFloat { - labelFits ? 0 : (labelWidth + Self.contentSpacing) / 2 + // A dragged tab is promoted to the full-label slot by the parent + // layout. Keep the glyph at its resting x position while the text + // reveals, instead of animating it from the icon-only center. + isDragged || labelFits ? 0 : (labelWidth + Self.contentSpacing) / 2 } private static let contentSpacing: CGFloat = 4 @@ -417,7 +423,7 @@ struct ModeBarButton: View { .frame(minWidth: 0, alignment: .leading) .clipped() .mask { ModeBarLabelEdgeFade(naturalWidth: naturalLabelWidth) } - .opacity(labelFits ? 1 : 0) + .opacity(isDragged ? 1 : (labelFits ? 1 : 0)) .onGeometryChange(for: CGFloat.self) { proxy in proxy.size.width } action: { width in diff --git a/Sources/RightSidebarModeBarDrag.swift b/Sources/RightSidebarModeBarDrag.swift index 424830878961..f724d1a80680 100644 --- a/Sources/RightSidebarModeBarDrag.swift +++ b/Sources/RightSidebarModeBarDrag.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxFoundation import Observation import CmuxSidebar import SwiftUI @@ -12,8 +13,9 @@ import SwiftUI @Observable final class RightSidebarModeBarDragController { nonisolated static let coordinateSpace = "RightSidebarModeBarTabs" - /// Gentle and nearly critically damped: the tabs part without bouncing. - static let spring = Animation.spring(response: 0.28, dampingFraction: 0.86) + /// A longer, nearly critically damped spring keeps lateral tab movement + /// gradual while staying responsive when the order changes. + static let spring = Animation.spring(response: 0.34, dampingFraction: 0.9) struct Session { let mode: RightSidebarMode @@ -69,7 +71,7 @@ final class RightSidebarModeBarDragController { guard var current = session, !current.isCarriedOut else { return } let tabFrame = current.layout.frames[current.layout.source] if mode.canOpenAsPane, - RightSidebarModeBarDragLayout.leavesBar(pointerY: location.y, tabFrame: tabFrame, barHeight: barHeight), + RightSidebarModeBarDragLayout.leavesBar(pointer: location, tabFrame: tabFrame, barHeight: barHeight), carryOut(current, tabFrame: tabFrame, travel: travel, animation: animation, dragImage: dragImage) { return } @@ -147,7 +149,17 @@ final class RightSidebarModeBarDragController { guard let view = anchor.view, let event = NSApp.currentEvent, event.type == .leftMouseDragged, let image = dragImage(tabFrame.size) else { return false } - let frame = tabFrame.offsetBy(dx: current.layout.draggedOffset(translation: translation), dy: travel.height) + let lifted = tabFrame.offsetBy(dx: current.layout.draggedOffset(translation: translation), dy: travel.height) + // AppKit scales the drag contents to the dragging frame. Unfocused + // tabs can have an icon-only resting frame, so use the preview's full + // intrinsic size for the frame while keeping the press point anchored. + let grabX = current.startLocation.x - tabFrame.minX + let frame = NSRect( + x: lifted.minX + max(0, grabX - image.size.width + image.size.height / 2), + y: lifted.midY - image.size.height / 2, + width: image.size.width, + height: image.size.height + ) guard let source = RightSidebarModeDragPayload.beginPaneDrag( mode: current.mode, from: view, event: event, frame: frame, image: image, onEnd: { [weak self] in self?.paneDragEnded() } @@ -257,7 +269,10 @@ struct RightSidebarModeBarTabDrag: ViewModifier { private func dragImage(size: CGSize) -> NSImage? { let renderer = ImageRenderer( content: RightSidebarModeBarDragPreview(mode: mode) - .frame(width: size.width, height: size.height) + // Keep the preview at its intrinsic width. The resting tab + // can be narrower while the drag image must retain the full label. + .fixedSize(horizontal: true, vertical: false) + .frame(minWidth: size.width, minHeight: size.height) .environment(\.colorScheme, colorScheme) ) renderer.scale = controller.anchor.view?.window?.backingScaleFactor ?? 2 @@ -268,16 +283,23 @@ struct RightSidebarModeBarTabDrag: ViewModifier { /// The tab as it looks selected, for the image a carried-out drag shows. private struct RightSidebarModeBarDragPreview: View { let mode: RightSidebarMode + @Environment(\.cmuxGlobalFontMagnificationPercent) private var globalFontPercent var body: some View { HStack(spacing: 4) { - CmuxSystemSymbolImage( + // ImageRenderer cannot capture the NSViewRepresentable used by + // CmuxSystemSymbolImage. Supply its materialized bitmap instead. + if let image = RenderableSystemSymbol.configuredAppKitImage( systemName: mode.symbolName, - pointSize: RightSidebarChromeControlStyle.modeIconSize, - weight: RightSidebarChromeControlStyle.iconWeight, - tint: RightSidebarChromeControlStyle.pillForegroundColor(isSelected: true, isHovered: true), - appliesGlobalFontMagnification: true - ) + pointSize: GlobalFontMagnification.scaledSize( + RightSidebarChromeControlStyle.modeIconSize, percent: globalFontPercent + ), + weight: RightSidebarChromeControlStyle.iconWeight + ) { + Image(nsImage: image) + .renderingMode(.template) + .foregroundStyle(RightSidebarChromeControlStyle.pillForegroundColor(isSelected: true, isHovered: true)) + } Text(mode.label) .cmuxFont(size: RightSidebarChromeControlStyle.labelSize, weight: RightSidebarChromeControlStyle.labelWeight) .lineLimit(1) diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index 9f574d6ce458..32199542effe 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -232,6 +232,7 @@ struct RightSidebarPanelView: View { isSelected: item.isSelected( mode: fileExplorerState.mode ), + isDragged: modeBarDrag.isLifted(item.mode), badgeCount: item.mode == .feed ? feedPendingCount : 0, shortcutHint: shortcut, showsShortcutHint: ShortcutHintTitlebarPolicy.shouldShow( @@ -254,7 +255,12 @@ struct RightSidebarPanelView: View { mode: item.mode, displayedModes: displayedModes, barHeight: titlebarHeight, controller: modeBarDrag )) - .layoutValue(key: RightSidebarModeBarTabSelectedKey.self, value: item.isSelected(mode: fileExplorerState.mode)) + .layoutValue( + key: RightSidebarModeBarTabSelectedKey.self, + // Give the dragged tab the same full-label slot as + // the focused tab, even when it started unfocused. + value: item.isSelected(mode: fileExplorerState.mode) || modeBarDrag.isLifted(item.mode) + ) } } .animation(reduceMotion ? nil : ModeBarButton.switchAnimation, value: fileExplorerState.mode) diff --git a/cmuxTests/RightSidebarTabCustomizationTests.swift b/cmuxTests/RightSidebarTabCustomizationTests.swift index a98f37d2c4d7..ebd34dc90c13 100644 --- a/cmuxTests/RightSidebarTabCustomizationTests.swift +++ b/cmuxTests/RightSidebarTabCustomizationTests.swift @@ -172,9 +172,10 @@ final class RightSidebarTabCustomizationTests: XCTestCase { func testModeBarDragLayoutLeavesTheBarOnlyWellOutsideIt() { let tab = CGRect(x: 0, y: 7, width: 40, height: 24) - XCTAssertFalse(RightSidebarModeBarDragLayout.leavesBar(pointerY: 40, tabFrame: tab, barHeight: 38)) - XCTAssertTrue(RightSidebarModeBarDragLayout.leavesBar(pointerY: 51, tabFrame: tab, barHeight: 38)) - XCTAssertTrue(RightSidebarModeBarDragLayout.leavesBar(pointerY: -13, tabFrame: tab, barHeight: 38)) + XCTAssertFalse(RightSidebarModeBarDragLayout.leavesBar(pointer: CGPoint(x: 1, y: 40), tabFrame: tab, barHeight: 38)) + XCTAssertTrue(RightSidebarModeBarDragLayout.leavesBar(pointer: CGPoint(x: 1, y: 51), tabFrame: tab, barHeight: 38)) + XCTAssertTrue(RightSidebarModeBarDragLayout.leavesBar(pointer: CGPoint(x: 1, y: -13), tabFrame: tab, barHeight: 38)) + XCTAssertTrue(RightSidebarModeBarDragLayout.leavesBar(pointer: CGPoint(x: -1, y: 40), tabFrame: tab, barHeight: 38)) XCTAssertNil(RightSidebarModeBarDragLayout(frames: [], source: 0)) }