From 4ff5dd8e772c11c8ebcc7e23fb04c2bc7497dde0 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 19:26:35 -0700 Subject: [PATCH 1/2] fix(next): a dragged tab stays glass in a translucent window Ports the tab half of #22202 onto base: the lifted tab's fill, the tab drag image and the drag ghost follow the source scope's backdrop (WindowBackdrop dragsOnGlass). The sidebar half is superseded: base lifts a row on the overlay glass (u5506); its handoff now tells the ghost to stand the row's image on glass. Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15ba7-3152-78b2-bfdf-bb6eb0d5a053&host=cosmic-gray --- .../Drag/SidebarTabRowHandoff.swift | 5 +- .../CmuxNextApp/Drag/TabDragGhostPanel.swift | 27 ++- .../Drag/TabDragSession+Workspaces.swift | 2 +- .../CmuxNextApp/Drag/TabDragSession.swift | 12 +- .../WindowBackdrop+DragGlass.swift | 46 +++++ .../CmuxNextSidebar/Views/DragLiftView.swift | 2 + .../Views/SidebarView+WindowDrag.swift | 5 +- .../Groups/TabStripView+GroupDrag.swift | 4 +- .../Sources/CmuxNextTabs/TabCell.swift | 6 +- .../Sources/CmuxNextTabs/TabDragTypes.swift | 5 + .../CmuxNextTabs/TabSnapshotRenderer.swift | 18 +- .../CmuxNextTabs/TabStripView+Drag.swift | 16 +- .../CmuxNextAppTests/DragGlassTests.swift | 195 ++++++++++++++++++ .../SubtleElevationTests.swift | 4 +- 14 files changed, 311 insertions(+), 36 deletions(-) create mode 100644 Packages/macOS/CmuxNext/Sources/CmuxNextDesign/WindowBackdrop+DragGlass.swift create mode 100644 Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DragGlassTests.swift diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/SidebarTabRowHandoff.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/SidebarTabRowHandoff.swift index f492d7ab9a92..76d881421fd7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/SidebarTabRowHandoff.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/SidebarTabRowHandoff.swift @@ -19,8 +19,11 @@ enum SidebarTabRowHandoff { guard session.drag == nil, let paneModel = session.services.locateTab(handoff.tab.rawValue)?.1, let pane = session.services.paneController(for: paneModel) else { return false } let id = handoff.tab.rawValue + // No image: the ghost's glass follows the tab's pane, the scope it is dragged out of. + let onGlass = WindowBackdrop.dragPreview(pane.view.themeScope).dragsOnGlass session.begin(item: .tab(id), payload: .tab(id: id, sourceStripID: pane.stripModel.stripID), frame: handoff.rowScreenFrame, - grabOffset: handoff.grabOffset, point: handoff.screenPoint, image: nil, previewTab: id, draggedCount: 1, pane: pane) + grabOffset: handoff.grabOffset, point: handoff.screenPoint, image: nil, onGlass: onGlass, previewTab: id, + draggedCount: 1, pane: pane) return true } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragGhostPanel.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragGhostPanel.swift index 19f80b27b8bb..eb9f234286e5 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragGhostPanel.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragGhostPanel.swift @@ -8,7 +8,10 @@ import QuartzCore /// mouse and floats above every window. It draws the dragged tab's image /// and, as `cardness` rises, unfolds a Liquid Glass preview card under it /// with a live content thumbnail (terminal or browser snapshot). Over a -/// strip the card folds back into an inline tab. +/// strip the card folds back into an inline tab. In a translucent window the +/// tab image has no card of its own (`WindowBackdrop.dragsOnGlass`): the glass is under it from +/// the first frame, shaped like the tab until the card unfolds, so a picked-up +/// tab stays glass instead of turning into a solid theme block. /// /// The panel has a fixed size (the largest card plus shadow room) and only /// moves, so a frame costs one window move and a few layer frames. @@ -28,14 +31,18 @@ final class TabDragGhostPanel { /// size depends on them). private let pad: CGFloat private let inset: CGFloat + /// The tab image stands on the glass (a translucent window): the glass shows at every + /// cardness, and the glass edge, not a shadow, lifts it. + let onGlass: Bool - init(tabImage: CGImage?, tabSize: CGSize, grabOffset: CGPoint, aspect: CGFloat?, scale: CGFloat) { + /// - Parameter onGlass: The image was rendered without a card (`WindowBackdrop.dragCardFill`) + /// in the scope it was lifted from, not the app's: a workspace can pin its own background. + init(tabImage: CGImage?, tabSize: CGSize, grabOffset: CGPoint, aspect: CGFloat?, scale: CGFloat, onGlass: Bool) { + self.onGlass = onGlass self.tabSize = tabSize self.grabOffset = grabOffset - let pad = DragTunables.ghostPanelPad.value - let inset = DragTunables.ghostCardInset.value - self.pad = pad - self.inset = inset + pad = DragTunables.ghostPanelPad.value + inset = DragTunables.ghostCardInset.value cardSize = TabDragGeometry.cardSize(tabSize: tabSize, aspect: aspect, inset: inset) panelSize = CGSize(width: max(cardSize.width, tabSize.width * 1.6, 320) + pad * 2, height: max(cardSize.height, tabSize.height * 1.6) + pad * 2) @@ -61,7 +68,7 @@ final class TabDragGhostPanel { // The ghost is the whole content of a clear panel: before Liquid // Glass the card blurs the desktop under it. glass.samplesBehindWindow = true - glass.alphaValue = 0 + glass.alphaValue = onGlass ? 1 : 0 root.addSubview(glass) let overlay = CALayer() @@ -80,7 +87,7 @@ final class TabDragGhostPanel { tabLayer.contentsScale = scale tabLayer.shadowColor = ThemeScope.app.perform { Palette.shadow.cgColor } // The raised elevation step (PopupStyle.elevation2): a tight lift, not a dark halo. - tabLayer.shadowOpacity = Float(PopupStyle.elevation2.alpha) + tabLayer.shadowOpacity = onGlass ? 0 : Float(PopupStyle.elevation2.alpha) tabLayer.shadowRadius = PopupStyle.elevation2.layerRadius tabLayer.shadowOffset = PopupStyle.elevation2.layerOffset() overlay.addSublayer(tabLayer) @@ -131,13 +138,13 @@ final class TabDragGhostPanel { let tabLocal = tab.offsetBy(dx: -origin.x, dy: -origin.y) let current = layout.card.offsetBy(dx: -origin.x, dy: -origin.y) glass.frame = current - glass.alphaValue = c + glass.alphaValue = onGlass ? 1 : c let thumbHeight = max(0, current.height - tab.height - inset * 3) thumbLayer.frame = CGRect(x: current.minX + inset, y: current.minY + inset, width: max(0, current.width - inset * 2), height: thumbHeight) thumbLayer.opacity = Float(c) tabLayer.frame = tabLocal - tabLayer.shadowOpacity = Float(PopupStyle.elevation2.alpha * (1 - c)) + tabLayer.shadowOpacity = onGlass ? 0 : Float(PopupStyle.elevation2.alpha * (1 - c)) let s = layout.scale if s != 1 { // The root layer's anchor is (0, 0) (AppKit), so the pivot is explicit. diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession+Workspaces.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession+Workspaces.swift index b96fd3c33ba3..7ad7c968b124 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession+Workspaces.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession+Workspaces.swift @@ -26,7 +26,7 @@ extension TabDragSession { let ids = handoff.workspaceIDs.map(\.rawValue) let group: String? = if case let .group(id) = handoff.payload { id.rawValue } else { nil } begin(item: .workspaces(ids, group: group), payload: nil, frame: handoff.rowScreenFrame, grabOffset: handoff.grabOffset, - point: handoff.screenPoint, image: handoff.image, previewTab: previewTab(of: ids.first, in: window), + point: handoff.screenPoint, image: handoff.image, onGlass: handoff.imageOnGlass, previewTab: previewTab(of: ids.first, in: window), draggedCount: ids.count, pane: nil, window: window) return true } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession.swift index d04b64d175b6..3a0044a345a4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession.swift @@ -45,7 +45,7 @@ final class TabDragSession: NSObject { let item = Item.tab(start.tabID.rawValue) let payload = TabDragPayload.tab(id: start.tabID.rawValue, sourceStripID: start.stripID) begin(item: item, payload: payload, frame: start.screenFrame, grabOffset: start.grabOffset, point: start.screenPoint, - image: start.snapshot?.cgImage, previewTab: start.tabID.rawValue, draggedCount: 1, pane: pane) + image: start.snapshot?.cgImage, onGlass: start.snapshotOnGlass, previewTab: start.tabID.rawValue, draggedCount: 1, pane: pane) } func beginGroup(_ start: TabGroupDragStart, from pane: PaneController) { @@ -54,13 +54,14 @@ final class TabDragSession: NSObject { sourceStripID: start.stripID, width: start.screenFrame.width) let preview = pane.stripModel.selectedID.flatMap { start.tabIDs.contains($0) ? $0.rawValue : nil } ?? start.tabIDs.first?.rawValue begin(item: item, payload: payload, frame: start.screenFrame, grabOffset: start.grabOffset, point: start.screenPoint, - image: start.snapshot?.cgImage, previewTab: preview, draggedCount: start.tabIDs.count, pane: pane) + image: start.snapshot?.cgImage, onGlass: start.snapshotOnGlass, previewTab: preview, draggedCount: start.tabIDs.count, pane: pane) } /// `pane` is the source strip's pane (tab items); workspace items have - /// no pane and name their source `window`. + /// no pane and name their source `window`. `onGlass`: `image` was drawn for glass in the + /// source's scope (no card), which the ghost follows. func begin(item: Item, payload: TabDragPayload?, frame: CGRect, grabOffset: CGPoint, point: CGPoint, image: CGImage?, - previewTab: String?, draggedCount: Int, pane: PaneController?, window sourceWindow: WindowController? = nil) { + onGlass: Bool, previewTab: String?, draggedCount: Int, pane: PaneController?, window sourceWindow: WindowController? = nil) { if drag != nil { finish(commit: false) } finishLanding() @@ -70,7 +71,8 @@ final class TabDragSession: NSObject { let content = pane?.view.bounds ?? window?.content?.layoutView.bounds ?? .zero let aspect = content.width > 0 ? (content.height - Metrics.tabStripHeight) / content.width : nil let scale = window?.window?.backingScaleFactor ?? 2 - let ghost = TabDragGhostPanel(tabImage: image, tabSize: frame.size, grabOffset: grabOffset, aspect: aspect, scale: scale) + let ghost = TabDragGhostPanel(tabImage: image, tabSize: frame.size, grabOffset: grabOffset, aspect: aspect, scale: scale, + onGlass: onGlass) var motion = TabDragGhostMotion(rect: frame, cardness: 0, reduceMotion: !Motion.tabs.animatesMovement, rectSpring: Motion.tabs.spring(.track), morphSpring: Motion.tabs.spring(.appear)) // The card unfolds once its content preview arrives (`loadThumbnail`). diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/WindowBackdrop+DragGlass.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/WindowBackdrop+DragGlass.swift new file mode 100644 index 000000000000..20be8ba32812 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/WindowBackdrop+DragGlass.swift @@ -0,0 +1,46 @@ +public import AppKit + +/// What a drag preview stands on: a lifted tab in its strip and the drag ghost that follows the +/// pointer between windows (a lifted sidebar row is always the overlay glass, `DragLiftView`). +/// In a translucent window (any material but opaque) both are glass over the window's art and +/// tint, like the window they came from; only an opaque window (or Reduce Transparency) keeps the +/// solid theme card. Before this, both painted a flat theme fill, so a tab turned from glass to a +/// solid block the moment it was picked up. +public extension WindowBackdrop { + /// How much of the solid lift fill a lifted tab keeps over the window's own tint: enough that + /// the tabs sliding under it stay quiet, little enough that the art reads through. + static let dragLiftCoverage = 0.6 + + /// The backdrop a drag preview drawn in `scope` stands on: the window's (the same art, tuning + /// and tint `WindowRootView` paints), with the user's Reduce Transparency setting. Read in + /// the scope the preview is lifted from: a workspace that pins its own background, or none, + /// differs from the app's. + @MainActor static func dragPreview(_ scope: ThemeScope, + reduceTransparency: Bool = NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency) -> WindowBackdrop { + WindowBackdrop(scope.tokens, reduceTransparency: reduceTransparency, art: scope.backdropArt, + selection: scope.backdropSelection, tuning: scope.appearanceTuning, + tintOpacity: scope.backdropTintOpacity) + } + + /// Whether drag previews over this backdrop draw on glass. + var dragsOnGlass: Bool { !isOpaque } + + /// The lift fill's alpha over this backdrop's tint: between the tint and solid + /// (``dragLiftCoverage``), so a lifted tab reads as a thicker piece of the same glass. + var dragLiftAlpha: CGFloat { + let tint = min(max(tintOpacity, 0), 1) + return CGFloat(tint + (1 - tint) * Self.dragLiftCoverage) + } + + /// The fill of a lifted tab or a stacked card: `color` solid in an opaque window, else at + /// ``dragLiftAlpha``. + func dragLiftFill(_ color: NSColor) -> NSColor { + color.withAlphaComponent(dragsOnGlass ? dragLiftAlpha : 1) + } + + /// The card a drag image is rendered on: `color` in an opaque window; none over glass, where + /// the preview's own glass panel is the card. + func dragCardFill(_ color: NSColor) -> NSColor? { + dragsOnGlass ? nil : color.withAlphaComponent(1) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/DragLiftView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/DragLiftView.swift index 4f047a791fe7..4e3d6a417cf4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/DragLiftView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/DragLiftView.swift @@ -11,6 +11,8 @@ final class DragLiftView: NSView { private let card = NSView() /// The card's background: the shared overlay material, never taking the pointer. let background: OverlaySurfaceView + /// The card is glass (any overlay material but the opaque one). + var isGlass: Bool { background.material != .opaque } private let content: NSView private var stack: [NSView] = [] private let countBadge = ChromeLabel(labelWithString: "") diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+WindowDrag.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+WindowDrag.swift index 5f4d6651d1e0..77963b118e41 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+WindowDrag.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/SidebarView+WindowDrag.swift @@ -16,6 +16,9 @@ public struct SidebarDragHandoff { public var screenPoint: CGPoint /// Snapshot of the lifted row, for the drag ghost. public var image: CGImage? + /// The lifted row stands on glass (`DragLiftView.isGlass`), which a snapshot does not draw: the + /// ghost puts its own glass under `image`. + public var imageOnGlass = false } extension SidebarViewComponents { @@ -53,7 +56,7 @@ extension SidebarListViewComponents { let offer = SidebarDragHandoff( payload: drag.payload, workspaceIDs: members, rowScreenFrame: rowFrame, grabOffset: DragGrabPoint.screenOffset(of: grabbed, in: lift, flipped: isFlipped), - screenPoint: screenPoint, image: drag.lift.snapshotImage() + screenPoint: screenPoint, image: drag.lift.snapshotImage(), imageOnGlass: drag.lift.isGlass ) guard handoff(offer) else { return false } abandonDrag(drag) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Groups/TabStripView+GroupDrag.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Groups/TabStripView+GroupDrag.swift index 351e760b637f..93eb5e1c1826 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Groups/TabStripView+GroupDrag.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/Groups/TabStripView+GroupDrag.swift @@ -112,6 +112,7 @@ extension TabStripView { var layers: [CALayer] = [] if let band = groups.bands[drag.groupID] { layers += [band.washLayer, band.lineLayer] } layers += memberCells.map(\.layer) + [chip.layer] + let image = snapshot(of: layers, frame: frame) let start = TabGroupDragStart( groupID: drag.groupID, tabIDs: drag.memberIDs, @@ -122,7 +123,8 @@ extension TabStripView { grabOffset: DragGrabPoint.screenOffset(of: CGPoint(x: frame.minX + drag.grabOffset, y: drag.grabY), in: frame, flipped: tabsClip.isFlipped), screenPoint: pointer, - snapshot: snapshot(of: layers, frame: frame) + snapshot: image.image, + snapshotOnGlass: image.onGlass ) groups.drag = nil escapeMonitor.remove() diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift index fb4943be1f15..e756142cb8a6 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift @@ -288,9 +288,9 @@ final class TabCell { let multi = isMultiSelected && !isSelected let fill: NSColor? = fillsSelection ? Palette.selectionFill : multi ? Palette.secondarySelectionFill : (isHovered ? Palette.hoverFill : nil) backgroundLayer.backgroundColor = fill?.cgColor - if isLifted { - // A lifted tab reads as solid so it does not show tabs sliding under it. - backgroundLayer.backgroundColor = Palette.windowBackground.blended(withFraction: 0.08, of: Palette.textPrimary)?.cgColor + if isLifted, let lift = Palette.windowBackground.blended(withFraction: 0.08, of: Palette.textPrimary) { + // Keeps the tabs sliding under it quiet: solid when opaque, thicker glass when translucent. + backgroundLayer.backgroundColor = WindowBackdrop.dragPreview(themeScope).dragLiftFill(lift).cgColor } // A hibernated tab keeps its title color (Chrome's discarded tabs do too); the ring marks it. let text = isSelected ? Palette.textPrimary : Palette.textSecondary diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabDragTypes.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabDragTypes.swift index c8cb7920b116..b9edb5fa7f1d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabDragTypes.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabDragTypes.swift @@ -12,6 +12,9 @@ public struct TabDragStart: Equatable, Sendable { public var screenPoint: CGPoint /// Image of the tab itself. The session may prefer a content thumbnail. public var snapshot: TabImage? + /// `snapshot` was drawn for glass, without a card (`WindowBackdrop.dragCardFill` in the + /// strip's scope): the ghost stands it on glass. + public var snapshotOnGlass = false } /// Result of hit-testing a strip during an external drag. @@ -38,4 +41,6 @@ public struct TabGroupDragStart: Equatable, Sendable { public var screenPoint: CGPoint /// Image of the chip and member tabs. public var snapshot: TabImage? + /// `snapshot` was drawn for glass (see `TabDragStart.snapshotOnGlass`). + public var snapshotOnGlass = false } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabSnapshotRenderer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabSnapshotRenderer.swift index 30103f369cb9..de014211fb15 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabSnapshotRenderer.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabSnapshotRenderer.swift @@ -5,11 +5,12 @@ import QuartzCore struct TabSnapshotRenderer { let metrics: TabStripMetrics let scale: CGFloat - /// The card behind the layers (the window background, resolved in the strip's theme). - let background: CGColor + /// The card behind the layers (the window background, resolved in the strip's theme); nil + /// draws none (a translucent window: the drag ghost's glass is the card). + let background: CGColor? /// Renders `layers` (tab or chip roots in the clip layer) into one image - /// covering `frame` (clip coordinates), on a window-colored rounded card. + /// covering `frame` (clip coordinates), on `background`'s rounded card when there is one. func image(of layers: [CALayer], frame: CGRect) -> TabImage? { let size = frame.size let width = Int((size.width * scale).rounded()) @@ -21,10 +22,13 @@ struct TabSnapshotRenderer { // Layers are flipped; CoreGraphics is not. context.translateBy(x: 0, y: CGFloat(height)) context.scaleBy(x: scale, y: -scale) - context.setFillColor(background) - let radius = metrics.cornerRadius - context.addPath(CGPath(roundedRect: metrics.pillFrame(slotWidth: size.width, height: size.height), cornerWidth: radius, cornerHeight: radius, transform: nil)) - context.fillPath() + if let background { + context.setFillColor(background) + let radius = metrics.cornerRadius + context.addPath(CGPath(roundedRect: metrics.pillFrame(slotWidth: size.width, height: size.height), cornerWidth: radius, cornerHeight: radius, + transform: nil)) + context.fillPath() + } for layer in layers { context.saveGState() context.translateBy(x: layer.frame.minX - frame.minX, y: layer.frame.minY - frame.minY) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift index 24e208588bab..dfd305845859 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabStripView+Drag.swift @@ -188,13 +188,15 @@ extension TabStripView { let screenFrame = window.convertToScreen(frameInWindow) let pointer = window.convertPoint(toScreen: event.locationInWindow) let grabbed = CGPoint(x: cell.frame.minX + drag.grabOffset, y: drag.grabY) + let image = snapshot(of: [cell.layer], frame: cell.frame) let start = TabDragStart( tabID: drag.id, stripID: model.stripID, screenFrame: screenFrame, grabOffset: DragGrabPoint.screenOffset(of: grabbed, in: cell.frame, flipped: tabsClip.isFlipped), screenPoint: pointer, - snapshot: snapshot(of: [cell.layer], frame: cell.frame) + snapshot: image.image, + snapshotOnGlass: image.onGlass ) self.drag = nil escapeMonitor.remove() @@ -207,10 +209,14 @@ extension TabStripView { model.send(.dragBegan(start)) } - /// The drag image of `layers` (`TabSnapshotRenderer`), on a window-colored rounded card. - func snapshot(of layers: [CALayer], frame: CGRect) -> TabImage? { - let background = performWithTheme { Palette.windowBackground.cgColor } - return TabSnapshotRenderer(metrics: metrics, scale: window?.backingScaleFactor ?? 2, background: background).image(of: layers, frame: frame) + /// The drag image of `layers` (`TabSnapshotRenderer`), on a window-colored card only in an + /// opaque window, and whether it was drawn for glass (no card): decided in this strip's scope, + /// which the drag ghost must follow. + func snapshot(of layers: [CALayer], frame: CGRect) -> (image: TabImage?, onGlass: Bool) { + let backdrop = WindowBackdrop.dragPreview(themeScope) + let background = performWithTheme { backdrop.dragCardFill(Palette.windowBackground)?.cgColor } + let image = TabSnapshotRenderer(metrics: metrics, scale: window?.backingScaleFactor ?? 2, background: background).image(of: layers, frame: frame) + return (image, backdrop.dragsOnGlass) } /// The tabs the strip shows, in order (tab conservation check DP1: once diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DragGlassTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DragGlassTests.swift new file mode 100644 index 000000000000..8173f51e719a --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/DragGlassTests.swift @@ -0,0 +1,195 @@ +import AppKit +@testable import CmuxNextApp +import CmuxNextBridge +@testable import CmuxNextDaemon +@testable import CmuxNextDesign +@testable import CmuxNextSidebar +@testable import CmuxNextTabs +import Testing + +/// Leo, 2026-10-19: "the dragged workspaces and dragged tabs turning from glassy to solid themed is +/// so shitty". In a translucent window a picked-up tab and the drag ghost are glass over the +/// window's art and tint (`WindowBackdrop.dragsOnGlass`), not a flat theme card; a lifted sidebar row +/// is the overlay glass (`DragLiftView`) and its ghost keeps it; a window opened for a torn-off +/// workspace or tab gets the same backdrop as the window it left. +@MainActor @Suite(.serialized) struct DragGlassTests { + /// Runs `body` with the app theme translucent and frosted, as cmux.json's + /// `appearance.backgroundOpacity` 0.65 and `backgroundBlur` "frosted" make it. + func translucent(_ body: () throws -> T) rethrows -> T { + let original = ThemeStore.shared.input + defer { _ = ThemeStore.shared.apply(original) } + var input = original + input.backgroundOpacity = 0.65 + input.backgroundBlur = 20 + _ = ThemeStore.shared.apply(input) + return try body() + } + + static var reducesTransparency: Bool { NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency } + + @Test func liftAndCardFillsAreGlassOnlyInATranslucentWindow() { + var input = ThemeInput.ghosttyDefault + input.backgroundOpacity = 0.65 + let glass = WindowBackdrop(ThemeTokens.derive(from: input)) + let opaque = WindowBackdrop(ThemeTokens.derive(from: .ghosttyDefault)) + #expect(glass.dragsOnGlass) + #expect(!opaque.dragsOnGlass) + let lift = glass.dragLiftFill(.red).alphaComponent + #expect(lift > CGFloat(glass.tintOpacity) && lift < 1, "a thicker piece of the window's glass, never solid") + #expect(lift == glass.dragLiftAlpha) + #expect(opaque.dragLiftFill(.red).alphaComponent == 1) + #expect(glass.dragCardFill(.red) == nil, "the ghost's glass is the card") + #expect(opaque.dragCardFill(.red)?.alphaComponent == 1) + } + + /// The lifted row is the overlay glass in every window (u5506) but under Reduce Transparency; + /// its snapshot does not draw the glass, so the handoff tells the ghost to. + @Test func aLiftedSidebarRowHandsItsGlassToTheGhost() { + #expect(DragLiftView(content: NSView(), count: 1, material: .liquidGlass).isGlass) + #expect(DragLiftView(content: NSView(), count: 1, material: .vibrancy).isGlass) + #expect(!DragLiftView(content: NSView(), count: 1, material: .opaque).isGlass) + } + + @Test func aLiftedTabLetsTheWindowsGlassReadThrough() throws { + try translucent { + let cell = TabCell(item: TabItem(id: TabID("t"), title: "New Tab")) + cell.isLifted = true + let fill = try #require(cell.backgroundLayer.backgroundColor) + if Self.reducesTransparency { #expect(fill.alpha == 1) } else { #expect(fill.alpha < 1) } + } + } + + @Test func theDragGhostStandsOnGlassFromItsFirstFrame() throws { + let ghost = TabDragGhostPanel(tabImage: nil, tabSize: CGSize(width: 160, height: 28), + grabOffset: CGPoint(x: 20, y: 14), aspect: 1.6, scale: 2, onGlass: true) + ghost.render(TabDragGhostMotion(rect: CGRect(x: 100, y: 100, width: 160, height: 28), cardness: 0)) + let root = try #require(ghost.panel.contentView) + let glass = try #require(root.subviews.first { $0 is GlassPanelView }) + #expect(glass.alphaValue == 1, "the tab is glass while it is a tab, not only once the card unfolds") + #expect(glass.frame.size == CGSize(width: 160, height: 28)) + + let solid = TabDragGhostPanel(tabImage: nil, tabSize: CGSize(width: 160, height: 28), + grabOffset: CGPoint(x: 20, y: 14), aspect: 1.6, scale: 2, onGlass: false) + solid.render(TabDragGhostMotion(rect: CGRect(x: 100, y: 100, width: 160, height: 28), cardness: 0)) + let solidGlass = try #require(solid.panel.contentView?.subviews.first { $0 is GlassPanelView }) + #expect(solidGlass.alphaValue == 0, "an opaque window's tab image carries its own card") + } + + /// Runs `body` with the app theme opaque and without art, so only a workspace's own pin can + /// make a drag preview glass. + func opaqueAppWithoutArt(_ body: () throws -> T) rethrows -> T { + let original = ThemeStore.shared.input + let originalSelection = ThemeScope.app.backdropSelection + defer { + _ = ThemeStore.shared.apply(original) + ThemeScope.app.setBackdropSelection(originalSelection) + } + var input = original + input.backgroundOpacity = 1 + input.backgroundBlur = 0 + _ = ThemeStore.shared.apply(input) + ThemeScope.app.setBackdropSelection(nil) + return try body() + } + + /// Review of #22202: the drag ghost read the APP scope while the images under it are drawn in + /// the SOURCE scope, so a workspace with art of its own over an opaque app dropped its card + /// for glass and the ghost then drew no glass under it. Every source now hands its own + /// scope's answer to the ghost: a strip's tab image, and the ghost the drag session flies for a + /// sidebar row's handoff. + @Test func aWorkspacesOwnBackgroundDecidesTheGhostsGlassNotTheApps() async throws { + opaqueAppWithoutArt { + #expect(WindowBackdrop.dragPreview(ThemeScope.app).isOpaque, "the fixture's app is opaque") + let workspace = ThemeScope(level: .workspace) + workspace.pinBackdrop(.selection(.art(.goyaSaturn))) + let glass = !Self.reducesTransparency + #expect(WindowBackdrop.dragPreview(workspace).dragsOnGlass == glass) + + // A strip in the workspace: its tab image is drawn for glass. + let host = NSView(frame: NSRect(x: 0, y: 0, width: 600, height: TabStripView.preferredHeight)) + let window = NSWindow(contentRect: host.frame, styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + window.contentView = host + workspace.root(host) + let strip = TabStripView(model: TabStripModel(tabs: [TabItem(id: TabID("t"), title: "cmux")], selectedID: TabID("t"))) + strip.frame = host.bounds + host.addSubview(strip) + #expect(strip.snapshot(of: [], frame: CGRect(x: 0, y: 0, width: 160, height: 28)).onGlass == glass) + } + + // The drag session's ghost follows the handoff, not the app's opaque theme. + _ = NSApplication.shared + let services = AppServices(environment: AppEnvironment.current([:])) + let store = services.machines.local.store + let json = #"{"app":"cmux-tui","protocol":1,"session":"s-local","generation":"g1"}"# + let identity = try JSONDecoder().decode(DaemonIdentity.self, from: Data(json.utf8)) + store.noteHandshake(identity) + store.connectionState = .connected(identity) + var tree = DaemonTree(registryID: "s-local", workspaces: [ + WorkspaceSnapshot(id: WorkspaceHandle(rawValue: 1), key: WorkspaceKey(rawValue: "k1"), name: "k1"), + ]) + tree.personal = PersonalState(workspaces: [ + PersonalWorkspace(sessionID: "s-local", workspaceKey: WorkspaceKey(rawValue: "k1"), index: 0), + ]) + store.apply(snapshot: tree) + let record = try JSONDecoder().decode(WindowRecord.self, from: Data(#"{"id":"drag-glass","workspace_key":"k1"}"#.utf8)) + let state = WindowState(record: record) + services.windows.registry.provisional[state.id] = ["k1"] + let controller = WindowController(state: state, services: services, frame: NSRect(x: 200, y: 200, width: 1100, height: 720)) + defer { + controller.teardown() + controller.window?.close() + } + let session = services.dragSession + for onGlass in [true, false] { + try opaqueAppWithoutArt { + let handoff = SidebarDragHandoff(payload: .workspaces([WorkspaceID("k1")]), workspaceIDs: [WorkspaceID("k1")], + rowScreenFrame: CGRect(x: 300, y: 300, width: 220, height: 28), + grabOffset: CGPoint(x: 20, y: 14), screenPoint: CGPoint(x: 320, y: 314), + image: nil, imageOnGlass: onGlass) + #expect(session.beginWorkspaces(handoff, from: controller)) + let ghost = try #require(session.drag?.ghost) + #expect(ghost.onGlass == onGlass, "the ghost stands on the glass its image was drawn for") + session.finish(commit: false) + session.finishLanding() + } + } + } + + /// The boundary: a window opened later (a torn-off workspace or tab) with its own room scope + /// showing a new workspace scope gets the main window's window opacity, background, blur and + /// material, through the one install path every main window takes. + @Test func aNewSecondaryWindowHasTheMainWindowsBackdrop() throws { + try translucent { + @MainActor func open() -> (NSWindow, WindowRootView, ThemeScope) { + let root = WindowRootView(sidebar: SidebarContainerView(model: SidebarModel()), + reduceTransparency: { false }, applyWindowBlur: { _, _ in }) + let window = NSWindow(contentRect: root.frame, + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let room = ThemeScope(level: .room) + window.install(kind: .main, content: root, scope: room) + return (window, root, room) + } + let (main, mainRoot, _) = open() + defer { main.close() } + let (torn, tornRoot, tornRoom) = open() + defer { torn.close() } + let workspace = ThemeScope(level: .workspace) + tornRoom.show(workspace) + mainRoot.themeDidChange() + tornRoot.themeDidChange() + + #expect(!mainRoot.backdrop.isOpaque) + #expect(tornRoot.backdrop == mainRoot.backdrop) + #expect(torn.isOpaque == main.isOpaque) + #expect(!torn.isOpaque) + #expect(torn.backgroundColor == main.backgroundColor) + #expect(tornRoot.backdropView.material == mainRoot.backdropView.material) + #expect(tornRoot.backdropView.tintColor == mainRoot.backdropView.tintColor) + #expect(tornRoot.layer?.backgroundColor == nil, "no solid sheet under a translucent window's content") + } + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubtleElevationTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubtleElevationTests.swift index 928838e006e4..04020b7d8f12 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubtleElevationTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/SubtleElevationTests.swift @@ -71,7 +71,7 @@ struct SubtleElevationTests { @Test func theTabDragGhostCastsTheRaisedStep() throws { let ghost = TabDragGhostPanel(tabImage: nil, tabSize: CGSize(width: 160, height: 28), - grabOffset: CGPoint(x: 20, y: 14), aspect: 1.6, scale: 2) + grabOffset: CGPoint(x: 20, y: 14), aspect: 1.6, scale: 2, onGlass: false) let root = try #require(ghost.panel.contentView?.layer) let casting = Self.layers(in: root).filter { $0.shadowOpacity > 0 } #expect(!casting.isEmpty) @@ -94,7 +94,7 @@ struct SubtleElevationTests { let cell = TabCell(item: TabItem(id: TabID("t"), title: "cmux")) cell.isLifted = true let ghost = TabDragGhostPanel(tabImage: nil, tabSize: CGSize(width: 160, height: 28), - grabOffset: CGPoint(x: 20, y: 14), aspect: 1.6, scale: 2) + grabOffset: CGPoint(x: 20, y: 14), aspect: 1.6, scale: 2, onGlass: false) let views: [NSView] = [dock, try #require(ghost.panel.contentView), CmuxToastView(toast: CmuxToast(id: "t", message: "Saved"))] // Each view's layer tree, and its subviews' (a layer-backed subview outside a window is From 63625dd7bf87cf1cdccadcb94be6693cfac6b46b Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 20 Oct 2026 19:39:07 -0700 Subject: [PATCH 2/2] fix(next): keep CmuxNextApp source size flat for the drag-glass port Co-Authored-By: Claude Opus 5.5 Agent-Session: https://cmux.com/deeplink/agent?harness=claude&session=01a15ba7-3152-78b2-bfdf-bb6eb0d5a053&host=cosmic-gray --- .../Sources/CmuxNextApp/Drag/SidebarTabRowHandoff.swift | 9 +++------ .../Sources/CmuxNextApp/Drag/TabDragSession.swift | 3 +-- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/SidebarTabRowHandoff.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/SidebarTabRowHandoff.swift index 76d881421fd7..f1577e7842e2 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/SidebarTabRowHandoff.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/SidebarTabRowHandoff.swift @@ -18,12 +18,9 @@ enum SidebarTabRowHandoff { static func begin(_ handoff: SidebarTabRowDragHandoff, session: TabDragSession) -> Bool { guard session.drag == nil, let paneModel = session.services.locateTab(handoff.tab.rawValue)?.1, let pane = session.services.paneController(for: paneModel) else { return false } - let id = handoff.tab.rawValue - // No image: the ghost's glass follows the tab's pane, the scope it is dragged out of. - let onGlass = WindowBackdrop.dragPreview(pane.view.themeScope).dragsOnGlass - session.begin(item: .tab(id), payload: .tab(id: id, sourceStripID: pane.stripModel.stripID), frame: handoff.rowScreenFrame, - grabOffset: handoff.grabOffset, point: handoff.screenPoint, image: nil, onGlass: onGlass, previewTab: id, - draggedCount: 1, pane: pane) + let id = handoff.tab.rawValue, onGlass = WindowBackdrop.dragPreview(pane.view.themeScope).dragsOnGlass + session.begin(item: .tab(id), payload: .tab(id: id, sourceStripID: pane.stripModel.stripID), frame: handoff.rowScreenFrame, grabOffset: handoff.grabOffset, + point: handoff.screenPoint, image: nil, onGlass: onGlass, previewTab: id, draggedCount: 1, pane: pane) return true } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession.swift index 3a0044a345a4..269653d325c7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextApp/Drag/TabDragSession.swift @@ -71,8 +71,7 @@ final class TabDragSession: NSObject { let content = pane?.view.bounds ?? window?.content?.layoutView.bounds ?? .zero let aspect = content.width > 0 ? (content.height - Metrics.tabStripHeight) / content.width : nil let scale = window?.window?.backingScaleFactor ?? 2 - let ghost = TabDragGhostPanel(tabImage: image, tabSize: frame.size, grabOffset: grabOffset, aspect: aspect, scale: scale, - onGlass: onGlass) + let ghost = TabDragGhostPanel(tabImage: image, tabSize: frame.size, grabOffset: grabOffset, aspect: aspect, scale: scale, onGlass: onGlass) var motion = TabDragGhostMotion(rect: frame, cardness: 0, reduceMotion: !Motion.tabs.animatesMovement, rectSpring: Motion.tabs.spring(.track), morphSpring: Motion.tabs.spring(.appear)) // The card unfolds once its content preview arrives (`loadThumbnail`).