Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +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
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)
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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()

Expand All @@ -70,7 +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)
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`).
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: "")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down
Loading