cmux/Sources/ContentView.swift
2026-02-15 19:59:55 -08:00

2334 lines
87 KiB
Swift

import AppKit
import SwiftUI
import ObjectiveC
import UniformTypeIdentifiers
struct ShortcutHintPillBackground: View {
var emphasis: Double = 1.0
var body: some View {
Capsule(style: .continuous)
.fill(.regularMaterial)
.overlay(
Capsule(style: .continuous)
.stroke(Color.white.opacity(0.30 * emphasis), lineWidth: 0.8)
)
.shadow(color: Color.black.opacity(0.22 * emphasis), radius: 2, x: 0, y: 1)
}
}
/// Applies NSGlassEffectView (macOS 26+) to a window, falling back to NSVisualEffectView
enum WindowGlassEffect {
private static var glassViewKey: UInt8 = 0
private static var tintOverlayKey: UInt8 = 0
static var isAvailable: Bool {
NSClassFromString("NSGlassEffectView") != nil
}
static func apply(to window: NSWindow, tintColor: NSColor? = nil) {
guard let originalContentView = window.contentView else { return }
// Check if we already applied glass (avoid re-wrapping)
if let existingGlass = objc_getAssociatedObject(window, &glassViewKey) as? NSView {
// Already applied, just update the tint
updateTint(on: existingGlass, color: tintColor, window: window)
return
}
let bounds = originalContentView.bounds
// Create the glass/blur view
let glassView: NSVisualEffectView
let usingGlassEffectView: Bool
// Try NSGlassEffectView first (macOS 26 Tahoe+)
if let glassClass = NSClassFromString("NSGlassEffectView") as? NSVisualEffectView.Type {
usingGlassEffectView = true
glassView = glassClass.init(frame: bounds)
glassView.wantsLayer = true
glassView.layer?.cornerRadius = 0
// Apply tint color via private API
if let color = tintColor {
let selector = NSSelectorFromString("setTintColor:")
if glassView.responds(to: selector) {
glassView.perform(selector, with: color)
}
}
} else {
usingGlassEffectView = false
// Fallback to NSVisualEffectView
glassView = NSVisualEffectView(frame: bounds)
glassView.blendingMode = .behindWindow
// Favor a lighter fallback so behind-window glass reads more transparent.
glassView.material = .underWindowBackground
glassView.state = .active
glassView.wantsLayer = true
}
glassView.autoresizingMask = [.width, .height]
if usingGlassEffectView {
// NSGlassEffectView is a full replacement for the contentView.
window.contentView = glassView
// Re-add the original SwiftUI hosting view on top of the glass, filling entire area.
originalContentView.translatesAutoresizingMaskIntoConstraints = false
originalContentView.wantsLayer = true
originalContentView.layer?.backgroundColor = NSColor.clear.cgColor
glassView.addSubview(originalContentView)
NSLayoutConstraint.activate([
originalContentView.topAnchor.constraint(equalTo: glassView.topAnchor),
originalContentView.bottomAnchor.constraint(equalTo: glassView.bottomAnchor),
originalContentView.leadingAnchor.constraint(equalTo: glassView.leadingAnchor),
originalContentView.trailingAnchor.constraint(equalTo: glassView.trailingAnchor)
])
} else {
// For NSVisualEffectView fallback (macOS 13-15), do NOT replace window.contentView.
// Replacing contentView can break traffic light rendering with
// `.fullSizeContentView` + `titlebarAppearsTransparent`.
glassView.translatesAutoresizingMaskIntoConstraints = false
originalContentView.addSubview(glassView, positioned: .below, relativeTo: nil)
NSLayoutConstraint.activate([
glassView.topAnchor.constraint(equalTo: originalContentView.topAnchor),
glassView.bottomAnchor.constraint(equalTo: originalContentView.bottomAnchor),
glassView.leadingAnchor.constraint(equalTo: originalContentView.leadingAnchor),
glassView.trailingAnchor.constraint(equalTo: originalContentView.trailingAnchor)
])
}
// Add tint overlay between glass and content (for fallback)
if let tintColor, !usingGlassEffectView {
let tintOverlay = NSView(frame: bounds)
tintOverlay.translatesAutoresizingMaskIntoConstraints = false
tintOverlay.wantsLayer = true
tintOverlay.layer?.backgroundColor = tintColor.cgColor
glassView.addSubview(tintOverlay)
NSLayoutConstraint.activate([
tintOverlay.topAnchor.constraint(equalTo: glassView.topAnchor),
tintOverlay.bottomAnchor.constraint(equalTo: glassView.bottomAnchor),
tintOverlay.leadingAnchor.constraint(equalTo: glassView.leadingAnchor),
tintOverlay.trailingAnchor.constraint(equalTo: glassView.trailingAnchor)
])
objc_setAssociatedObject(window, &tintOverlayKey, tintOverlay, .OBJC_ASSOCIATION_RETAIN)
}
// Store reference
objc_setAssociatedObject(window, &glassViewKey, glassView, .OBJC_ASSOCIATION_RETAIN)
}
/// Update the tint color on an existing glass effect
static func updateTint(to window: NSWindow, color: NSColor?) {
guard let glassView = objc_getAssociatedObject(window, &glassViewKey) as? NSView else { return }
updateTint(on: glassView, color: color, window: window)
}
private static func updateTint(on glassView: NSView, color: NSColor?, window: NSWindow) {
// For NSGlassEffectView, use setTintColor:
if glassView.className == "NSGlassEffectView" {
let selector = NSSelectorFromString("setTintColor:")
if glassView.responds(to: selector) {
glassView.perform(selector, with: color)
}
} else {
// For NSVisualEffectView fallback, update the tint overlay
if let tintOverlay = objc_getAssociatedObject(window, &tintOverlayKey) as? NSView {
tintOverlay.layer?.backgroundColor = color?.cgColor
}
}
}
static func remove(from window: NSWindow) {
// Note: Removing would require restoring original contentView structure
// For now, just clear the reference
objc_setAssociatedObject(window, &glassViewKey, nil, .OBJC_ASSOCIATION_RETAIN)
objc_setAssociatedObject(window, &tintOverlayKey, nil, .OBJC_ASSOCIATION_RETAIN)
}
}
final class SidebarState: ObservableObject {
@Published var isVisible: Bool = true
func toggle() {
isVisible.toggle()
}
}
struct ContentView: View {
@ObservedObject var updateViewModel: UpdateViewModel
let windowId: UUID
@Environment(\.colorScheme) private var colorScheme
@EnvironmentObject var tabManager: TabManager
@EnvironmentObject var notificationStore: TerminalNotificationStore
@EnvironmentObject var sidebarState: SidebarState
@EnvironmentObject var sidebarSelectionState: SidebarSelectionState
@State private var sidebarWidth: CGFloat = 200
@State private var sidebarMinX: CGFloat = 0
@State private var isResizerHovering = false
@State private var isResizerDragging = false
private let sidebarHandleWidth: CGFloat = 6
@State private var selectedTabIds: Set<UUID> = []
@State private var lastSidebarSelectionIndex: Int? = nil
@State private var titlebarText: String = ""
private var sidebarView: some View {
VerticalTabsSidebar(
updateViewModel: updateViewModel,
selection: $sidebarSelectionState.selection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
)
.frame(width: sidebarWidth)
.background(GeometryReader { proxy in
Color.clear
.preference(key: SidebarFramePreferenceKey.self, value: proxy.frame(in: .global))
})
.overlay(alignment: .trailing) {
Color.clear
.frame(width: sidebarHandleWidth)
.contentShape(Rectangle())
.accessibilityIdentifier("SidebarResizer")
.onHover { hovering in
if hovering {
if !isResizerHovering {
NSCursor.resizeLeftRight.push()
isResizerHovering = true
}
} else if isResizerHovering {
if !isResizerDragging {
NSCursor.pop()
isResizerHovering = false
}
}
}
.onDisappear {
if isResizerHovering || isResizerDragging {
NSCursor.pop()
isResizerHovering = false
isResizerDragging = false
}
}
.gesture(
DragGesture(minimumDistance: 0, coordinateSpace: .global)
.onChanged { value in
if !isResizerDragging {
isResizerDragging = true
if !isResizerHovering {
NSCursor.resizeLeftRight.push()
isResizerHovering = true
}
}
let nextWidth = max(186, min(360, value.location.x - sidebarMinX + sidebarHandleWidth / 2))
withTransaction(Transaction(animation: nil)) {
sidebarWidth = nextWidth
}
}
.onEnded { _ in
if isResizerDragging {
isResizerDragging = false
if !isResizerHovering {
NSCursor.pop()
}
}
}
)
}
}
/// Space at top of content area for the titlebar. This must be at least the actual titlebar
/// height; otherwise controls like Bonsplit tab dragging can be interpreted as window drags.
@State private var titlebarPadding: CGFloat = 32
private var terminalContent: some View {
ZStack {
ZStack {
ForEach(tabManager.tabs) { tab in
let isActive = tabManager.selectedTabId == tab.id
WorkspaceContentView(workspace: tab, isTabActive: isActive)
.opacity(isActive ? 1 : 0)
.allowsHitTesting(isActive)
}
}
.opacity(sidebarSelectionState.selection == .tabs ? 1 : 0)
.allowsHitTesting(sidebarSelectionState.selection == .tabs)
NotificationsPage(selection: $sidebarSelectionState.selection)
.opacity(sidebarSelectionState.selection == .notifications ? 1 : 0)
.allowsHitTesting(sidebarSelectionState.selection == .notifications)
}
.padding(.top, titlebarPadding)
.overlay(alignment: .top) {
// Titlebar overlay is only over terminal content, not the sidebar.
customTitlebar
}
}
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
// Background glass settings
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = true
@State private var titlebarLeadingInset: CGFloat = 12
private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" }
private var fakeTitlebarBackground: Color {
if colorScheme == .light {
return Color(nsColor: .windowBackgroundColor)
}
let ghosttyBackground = GhosttyApp.shared.defaultBackgroundColor
let alpha: CGFloat = ghosttyBackground.isLightColor ? 0.94 : 0.86
return Color(nsColor: ghosttyBackground.withAlphaComponent(alpha))
}
private var fakeTitlebarTextColor: Color {
colorScheme == .light ? Color(nsColor: .labelColor).opacity(0.78) : .secondary
}
private var fakeTitlebarSeparatorColor: Color {
Color(nsColor: .separatorColor).opacity(colorScheme == .light ? 0.68 : 0.34)
}
private var customTitlebar: some View {
ZStack {
// Enable window dragging from the titlebar strip without making the entire content
// view draggable (which breaks drag gestures like tab reordering).
WindowDragHandleView()
TitlebarLeadingInsetReader(inset: $titlebarLeadingInset)
HStack(spacing: 8) {
// Draggable folder icon + focused command name
if let directory = focusedDirectory {
DraggableFolderIcon(directory: directory)
}
Text(titlebarText)
.font(.system(size: 13, weight: .bold))
.foregroundColor(fakeTitlebarTextColor)
.lineLimit(1)
Spacer()
if !sidebarState.isVisible {
UpdatePill(model: updateViewModel)
.padding(.trailing, 8)
}
}
.frame(height: 28)
.padding(.top, 2)
.padding(.leading, sidebarState.isVisible ? 12 : titlebarLeadingInset)
.padding(.trailing, 8)
}
.frame(height: titlebarPadding)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
NSApp.keyWindow?.zoom(nil)
}
.background(fakeTitlebarBackground)
.overlay(alignment: .bottom) {
Rectangle()
.fill(fakeTitlebarSeparatorColor)
.frame(height: 1)
}
}
private func updateTitlebarText() {
guard let selectedId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else {
titlebarText = ""
return
}
let title = tab.title.trimmingCharacters(in: .whitespacesAndNewlines)
titlebarText = title
}
private var focusedDirectory: String? {
guard let selectedId = tabManager.selectedTabId,
let tab = tabManager.tabs.first(where: { $0.id == selectedId }) else {
return nil
}
// Use focused panel's directory if available
if let focusedPanelId = tab.focusedPanelId,
let panelDir = tab.panelDirectories[focusedPanelId] {
let trimmed = panelDir.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
}
let dir = tab.currentDirectory.trimmingCharacters(in: .whitespacesAndNewlines)
return dir.isEmpty ? nil : dir
}
var body: some View {
let useOverlay = sidebarBlendMode == SidebarBlendModeOption.withinWindow.rawValue
Group {
if useOverlay {
// Overlay mode: terminal extends full width, sidebar on top
// This allows withinWindow blur to see the terminal content
ZStack(alignment: .leading) {
terminalContent
.padding(.leading, sidebarState.isVisible ? sidebarWidth : 0)
if sidebarState.isVisible {
sidebarView
}
}
} else {
// Standard HStack mode for behindWindow blur
HStack(spacing: 0) {
if sidebarState.isVisible {
sidebarView
}
terminalContent
}
}
}
.frame(minWidth: 800, minHeight: 600)
.background(Color.clear)
.onAppear {
tabManager.applyWindowBackgroundForSelectedTab()
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
}
updateTitlebarText()
}
.onChange(of: tabManager.selectedTabId) { newValue in
tabManager.applyWindowBackgroundForSelectedTab()
guard let newValue else { return }
if selectedTabIds.count <= 1 {
selectedTabIds = [newValue]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == newValue }
}
updateTitlebarText()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidSetTitle)) { notification in
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
tabId == tabManager.selectedTabId else { return }
updateTitlebarText()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusTab)) { _ in
sidebarSelectionState.selection = .tabs
updateTitlebarText()
}
.onReceive(NotificationCenter.default.publisher(for: .ghosttyDidFocusSurface)) { notification in
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID,
tabId == tabManager.selectedTabId else { return }
updateTitlebarText()
}
.onReceive(tabManager.$tabs) { tabs in
let existingIds = Set(tabs.map { $0.id })
selectedTabIds = selectedTabIds.filter { existingIds.contains($0) }
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
}
if let lastIndex = lastSidebarSelectionIndex, lastIndex >= tabs.count {
if let selectedId = tabManager.selectedTabId {
lastSidebarSelectionIndex = tabs.firstIndex { $0.id == selectedId }
} else {
lastSidebarSelectionIndex = nil
}
}
}
.onPreferenceChange(SidebarFramePreferenceKey.self) { frame in
sidebarMinX = frame.minX
}
.onChange(of: bgGlassTintHex) { _ in
updateWindowGlassTint()
}
.onChange(of: bgGlassTintOpacity) { _ in
updateWindowGlassTint()
}
.ignoresSafeArea()
.background(WindowAccessor { [sidebarBlendMode, bgGlassEnabled, bgGlassTintHex, bgGlassTintOpacity] window in
window.identifier = NSUserInterfaceItemIdentifier(windowIdentifier)
window.titlebarAppearsTransparent = true
// Do not make the entire background draggable; it interferes with drag gestures
// like sidebar tab reordering in multi-window mode.
window.isMovableByWindowBackground = false
window.styleMask.insert(.fullSizeContentView)
// Keep content below the titlebar so drags on Bonsplit's tab bar don't
// get interpreted as window drags.
let computedTitlebarHeight = window.frame.height - window.contentLayoutRect.height
let nextPadding = max(28, min(72, computedTitlebarHeight))
if abs(titlebarPadding - nextPadding) > 0.5 {
DispatchQueue.main.async {
titlebarPadding = nextPadding
}
}
#if DEBUG
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" {
UpdateLogStore.shared.append("ui test window accessor: id=\(windowIdentifier) visible=\(window.isVisible)")
}
#endif
// Background glass: skip on macOS 26+ where NSGlassEffectView can cause blank
// or incorrectly tinted SwiftUI content. Keep native window rendering there so
// Ghostty theme colors remain authoritative.
if sidebarBlendMode == SidebarBlendModeOption.behindWindow.rawValue
&& bgGlassEnabled
&& !WindowGlassEffect.isAvailable {
window.isOpaque = false
window.backgroundColor = .clear
// Configure contentView and all subviews for transparency
if let contentView = window.contentView {
contentView.wantsLayer = true
contentView.layer?.backgroundColor = NSColor.clear.cgColor
contentView.layer?.isOpaque = false
// Make SwiftUI hosting view transparent
for subview in contentView.subviews {
subview.wantsLayer = true
subview.layer?.backgroundColor = NSColor.clear.cgColor
subview.layer?.isOpaque = false
}
}
// Apply liquid glass effect to the window with tint from settings
let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity)
WindowGlassEffect.apply(to: window, tintColor: tintColor)
}
AppDelegate.shared?.attachUpdateAccessory(to: window)
AppDelegate.shared?.applyWindowDecorations(to: window)
AppDelegate.shared?.registerMainWindow(
window,
windowId: windowId,
tabManager: tabManager,
sidebarState: sidebarState,
sidebarSelectionState: sidebarSelectionState
)
})
}
private func addTab() {
tabManager.addTab()
sidebarSelectionState.selection = .tabs
}
private func updateWindowGlassTint() {
// Find this view's main window by identifier (keyWindow might be a debug panel/settings).
guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == windowIdentifier }) else { return }
let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity)
WindowGlassEffect.updateTint(to: window, color: tintColor)
}
}
struct VerticalTabsSidebar: View {
@ObservedObject var updateViewModel: UpdateViewModel
@EnvironmentObject var tabManager: TabManager
@Binding var selection: SidebarSelection
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
@StateObject private var commandKeyMonitor = SidebarCommandKeyMonitor()
@StateObject private var dragAutoScrollController = SidebarDragAutoScrollController()
@State private var draggedTabId: UUID?
@State private var dropIndicator: SidebarDropIndicator?
/// Space at top of sidebar for traffic light buttons
private let trafficLightPadding: CGFloat = 28
private let tabRowSpacing: CGFloat = 2
var body: some View {
GeometryReader { proxy in
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
// Space for traffic lights
Spacer()
.frame(height: trafficLightPadding)
LazyVStack(spacing: tabRowSpacing) {
ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
TabItemView(
tab: tab,
index: index,
rowSpacing: tabRowSpacing,
selection: $selection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
showsCommandShortcutHints: commandKeyMonitor.isCommandPressed,
dragAutoScrollController: dragAutoScrollController,
draggedTabId: $draggedTabId,
dropIndicator: $dropIndicator
)
}
}
.padding(.vertical, 8)
SidebarEmptyArea(
rowSpacing: tabRowSpacing,
selection: $selection,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
dragAutoScrollController: dragAutoScrollController,
draggedTabId: $draggedTabId,
dropIndicator: $dropIndicator
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(minHeight: proxy.size.height, alignment: .top)
}
.background(
SidebarScrollViewResolver { scrollView in
dragAutoScrollController.attach(scrollView: scrollView)
}
.frame(width: 0, height: 0)
)
.overlay(alignment: .top) {
SidebarTopScrim(height: trafficLightPadding + 20)
.allowsHitTesting(false)
}
.background(Color.clear)
.modifier(ClearScrollBackground())
#if DEBUG
SidebarDevFooter(updateViewModel: updateViewModel)
.frame(maxWidth: .infinity, alignment: .leading)
#else
UpdatePill(model: updateViewModel)
.padding(.leading, 10)
.padding(.bottom, 10)
.frame(maxWidth: .infinity, alignment: .leading)
#endif
}
.accessibilityIdentifier("Sidebar")
}
.ignoresSafeArea()
.background(SidebarBackdrop().ignoresSafeArea())
.onAppear {
commandKeyMonitor.start()
draggedTabId = nil
dropIndicator = nil
}
.onDisappear {
commandKeyMonitor.stop()
dragAutoScrollController.stop()
draggedTabId = nil
dropIndicator = nil
}
.onChange(of: draggedTabId) { newDraggedTabId in
guard newDraggedTabId == nil else { return }
dragAutoScrollController.stop()
dropIndicator = nil
}
}
}
enum SidebarCommandHintPolicy {
static let intentionalHoldDelay: TimeInterval = 0.30
static func shouldShowHints(for modifierFlags: NSEvent.ModifierFlags) -> Bool {
modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command]
}
}
enum ShortcutHintDebugSettings {
static let sidebarHintXKey = "shortcutHintSidebarXOffset"
static let sidebarHintYKey = "shortcutHintSidebarYOffset"
static let titlebarHintXKey = "shortcutHintTitlebarXOffset"
static let titlebarHintYKey = "shortcutHintTitlebarYOffset"
static let paneHintXKey = "shortcutHintPaneTabXOffset"
static let paneHintYKey = "shortcutHintPaneTabYOffset"
static let alwaysShowHintsKey = "shortcutHintAlwaysShow"
static let defaultSidebarHintX = 0.0
static let defaultSidebarHintY = 0.0
static let defaultTitlebarHintX = 4.0
static let defaultTitlebarHintY = 0.0
static let defaultPaneHintX = 0.0
static let defaultPaneHintY = 0.0
static let defaultAlwaysShowHints = false
static let offsetRange: ClosedRange<Double> = -20...20
static func clamped(_ value: Double) -> Double {
min(max(value, offsetRange.lowerBound), offsetRange.upperBound)
}
}
@MainActor
private final class SidebarCommandKeyMonitor: ObservableObject {
@Published private(set) var isCommandPressed = false
private var flagsMonitor: Any?
private var keyDownMonitor: Any?
private var resignObserver: NSObjectProtocol?
private var pendingShowWorkItem: DispatchWorkItem?
func start() {
guard flagsMonitor == nil else {
update(from: NSEvent.modifierFlags)
return
}
flagsMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
self?.update(from: event.modifierFlags)
return event
}
keyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
self?.cancelPendingHintShow(resetVisible: true)
return event
}
resignObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didResignActiveNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in
self?.cancelPendingHintShow(resetVisible: true)
}
}
update(from: NSEvent.modifierFlags)
}
func stop() {
if let flagsMonitor {
NSEvent.removeMonitor(flagsMonitor)
self.flagsMonitor = nil
}
if let keyDownMonitor {
NSEvent.removeMonitor(keyDownMonitor)
self.keyDownMonitor = nil
}
if let resignObserver {
NotificationCenter.default.removeObserver(resignObserver)
self.resignObserver = nil
}
cancelPendingHintShow(resetVisible: true)
}
private func update(from modifierFlags: NSEvent.ModifierFlags) {
guard SidebarCommandHintPolicy.shouldShowHints(for: modifierFlags) else {
cancelPendingHintShow(resetVisible: true)
return
}
queueHintShow()
}
private func queueHintShow() {
guard !isCommandPressed else { return }
guard pendingShowWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingShowWorkItem = nil
guard SidebarCommandHintPolicy.shouldShowHints(for: NSEvent.modifierFlags) else { return }
self.isCommandPressed = true
}
pendingShowWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + SidebarCommandHintPolicy.intentionalHoldDelay, execute: workItem)
}
private func cancelPendingHintShow(resetVisible: Bool) {
pendingShowWorkItem?.cancel()
pendingShowWorkItem = nil
if resetVisible {
isCommandPressed = false
}
}
}
#if DEBUG
private struct SidebarDevFooter: View {
@ObservedObject var updateViewModel: UpdateViewModel
var body: some View {
VStack(alignment: .leading, spacing: 6) {
UpdatePill(model: updateViewModel)
Text("THIS IS A DEV BUILD")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.red)
}
.padding(.leading, 10)
.padding(.bottom, 10)
}
}
#endif
private struct SidebarTopScrim: View {
let height: CGFloat
var body: some View {
SidebarTopBlurEffect()
.frame(height: height)
.mask(
LinearGradient(
colors: [
Color.black.opacity(0.95),
Color.black.opacity(0.75),
Color.black.opacity(0.35),
Color.clear
],
startPoint: .top,
endPoint: .bottom
)
)
}
}
private struct SidebarTopBlurEffect: NSViewRepresentable {
func makeNSView(context: Context) -> NSVisualEffectView {
let view = NSVisualEffectView()
view.blendingMode = .withinWindow
view.material = .underWindowBackground
view.state = .active
view.isEmphasized = false
return view
}
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
}
private struct SidebarFramePreferenceKey: PreferenceKey {
static var defaultValue: CGRect = .zero
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
value = nextValue()
}
}
private struct SidebarScrollViewResolver: NSViewRepresentable {
let onResolve: (NSScrollView?) -> Void
func makeNSView(context: Context) -> SidebarScrollViewResolverView {
let view = SidebarScrollViewResolverView()
view.onResolve = onResolve
return view
}
func updateNSView(_ nsView: SidebarScrollViewResolverView, context: Context) {
nsView.onResolve = onResolve
nsView.resolveScrollView()
}
}
private final class SidebarScrollViewResolverView: NSView {
var onResolve: ((NSScrollView?) -> Void)?
override func viewDidMoveToSuperview() {
super.viewDidMoveToSuperview()
resolveScrollView()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
resolveScrollView()
}
func resolveScrollView() {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
onResolve?(self.enclosingScrollView)
}
}
}
private struct SidebarEmptyArea: View {
@EnvironmentObject var tabManager: TabManager
let rowSpacing: CGFloat
@Binding var selection: SidebarSelection
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
let dragAutoScrollController: SidebarDragAutoScrollController
@Binding var draggedTabId: UUID?
@Binding var dropIndicator: SidebarDropIndicator?
var body: some View {
Color.clear
.contentShape(Rectangle())
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onTapGesture(count: 2) {
tabManager.addTab()
if let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
}
selection = .tabs
}
.onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate(
targetTabId: nil,
tabManager: tabManager,
draggedTabId: $draggedTabId,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
targetRowHeight: nil,
dragAutoScrollController: dragAutoScrollController,
dropIndicator: $dropIndicator
))
.overlay(alignment: .top) {
if shouldShowTopDropIndicator {
Rectangle()
.fill(Color.accentColor)
.frame(height: 2)
.padding(.horizontal, 8)
.offset(y: -(rowSpacing / 2))
}
}
}
private var shouldShowTopDropIndicator: Bool {
guard draggedTabId != nil, let indicator = dropIndicator else { return false }
if indicator.tabId == nil {
return true
}
guard indicator.edge == .bottom, let lastTabId = tabManager.tabs.last?.id else { return false }
return indicator.tabId == lastTabId
}
}
private struct TabItemView: View {
@EnvironmentObject var tabManager: TabManager
@EnvironmentObject var notificationStore: TerminalNotificationStore
@ObservedObject var tab: Tab
let index: Int
let rowSpacing: CGFloat
@Binding var selection: SidebarSelection
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
let showsCommandShortcutHints: Bool
let dragAutoScrollController: SidebarDragAutoScrollController
@Binding var draggedTabId: UUID?
@Binding var dropIndicator: SidebarDropIndicator?
@State private var isHovering = false
@State private var rowHeight: CGFloat = 1
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
var isActive: Bool {
tabManager.selectedTabId == tab.id
}
var isMultiSelected: Bool {
selectedTabIds.contains(tab.id)
}
private var isBeingDragged: Bool {
draggedTabId == tab.id
}
private var workspaceShortcutDigit: Int? {
WorkspaceShortcutMapper.commandDigitForWorkspace(at: index, workspaceCount: tabManager.tabs.count)
}
private var showCloseButton: Bool {
isHovering && tabManager.tabs.count > 1 && !(showsCommandShortcutHints || alwaysShowShortcutHints)
}
private var workspaceShortcutLabel: String? {
guard let workspaceShortcutDigit else { return nil }
return "\(workspaceShortcutDigit)"
}
private var showsWorkspaceShortcutHint: Bool {
(showsCommandShortcutHints || alwaysShowShortcutHints) && workspaceShortcutLabel != nil
}
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
}
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
let unreadCount = notificationStore.unreadCount(forTabId: tab.id)
if unreadCount > 0 {
ZStack {
Circle()
.fill(isActive ? Color.white.opacity(0.25) : Color.accentColor)
Text("\(unreadCount)")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(.white)
}
.frame(width: 16, height: 16)
}
if tab.isPinned {
Image(systemName: "pin.fill")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
}
Text(tab.title)
.font(.system(size: 12.5, weight: .semibold))
.foregroundColor(isActive ? .white : .primary)
.lineLimit(1)
.truncationMode(.tail)
Spacer()
ZStack(alignment: .trailing) {
Button(action: { tabManager.closeWorkspaceWithConfirmation(tab) }) {
Image(systemName: "xmark")
.font(.system(size: 9, weight: .medium))
.foregroundColor(isActive ? .white.opacity(0.7) : .secondary)
}
.buttonStyle(.plain)
.help("Close Workspace (\(StoredShortcut(key: "w", command: true, shift: true, option: false, control: false).displayString))")
.frame(width: 16, height: 16, alignment: .center)
.opacity(showCloseButton && !showsWorkspaceShortcutHint ? 1 : 0)
.allowsHitTesting(showCloseButton && !showsWorkspaceShortcutHint)
if showsWorkspaceShortcutHint, let workspaceShortcutLabel {
Text(workspaceShortcutLabel)
.lineLimit(1)
.fixedSize(horizontal: true, vertical: false)
.font(.system(size: 10, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundColor(isActive ? .white : .primary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(ShortcutHintPillBackground(emphasis: isActive ? 1.0 : 0.9))
.offset(
x: ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset),
y: ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)
)
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.14), value: showsCommandShortcutHints || alwaysShowShortcutHints)
.frame(width: workspaceHintSlotWidth, height: 16, alignment: .trailing)
}
if let subtitle = latestNotificationText {
Text(subtitle)
.font(.system(size: 10))
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
.lineLimit(2)
.truncationMode(.tail)
.multilineTextAlignment(.leading)
}
if let directories = directorySummary {
Text(directories)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(backgroundColor)
)
.padding(.horizontal, 6)
.background {
GeometryReader { proxy in
Color.clear
.onAppear {
rowHeight = max(proxy.size.height, 1)
}
.onChange(of: proxy.size.height) { newHeight in
rowHeight = max(newHeight, 1)
}
}
}
.contentShape(Rectangle())
.opacity(isBeingDragged ? 0.6 : 1)
.overlay {
MiddleClickCapture {
tabManager.closeWorkspaceWithConfirmation(tab)
}
}
.overlay(alignment: .top) {
if showsCenteredTopDropIndicator {
Rectangle()
.fill(Color.accentColor)
.frame(height: 2)
.padding(.horizontal, 8)
.offset(y: index == 0 ? 0 : -(rowSpacing / 2))
}
}
.onDrag {
draggedTabId = tab.id
dropIndicator = nil
return SidebarTabDragPayload.provider(for: tab.id)
}
.onDrop(of: [SidebarTabDragPayload.typeIdentifier], delegate: SidebarTabDropDelegate(
targetTabId: tab.id,
tabManager: tabManager,
draggedTabId: $draggedTabId,
selectedTabIds: $selectedTabIds,
lastSidebarSelectionIndex: $lastSidebarSelectionIndex,
targetRowHeight: rowHeight,
dragAutoScrollController: dragAutoScrollController,
dropIndicator: $dropIndicator
))
.onTapGesture {
updateSelection()
}
.onHover { hovering in
isHovering = hovering
}
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(accessibilityTitle))
.accessibilityHint(Text("Activate to focus this workspace. Drag to reorder, or use Move Up and Move Down actions."))
.accessibilityAction(named: Text("Move Up")) {
moveBy(-1)
}
.accessibilityAction(named: Text("Move Down")) {
moveBy(1)
}
.contextMenu {
let targetIds = contextTargetIds()
let shouldPin = !tab.isPinned
let pinLabel = targetIds.count > 1
? (shouldPin ? "Pin Tabs" : "Unpin Tabs")
: (shouldPin ? "Pin Tab" : "Unpin Tab")
Button(pinLabel) {
for id in targetIds {
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
tabManager.setPinned(tab, pinned: shouldPin)
}
}
syncSelectionAfterMutation()
}
Button("Rename Tab…") {
promptRename()
}
if tab.hasCustomTitle {
Button("Remove Custom Name") {
tabManager.clearCustomTitle(tabId: tab.id)
}
}
Divider()
Button("Move Up") {
moveBy(-1)
}
.disabled(index == 0)
Button("Move Down") {
moveBy(1)
}
.disabled(index >= tabManager.tabs.count - 1)
Divider()
Button("Close Tabs") {
closeTabs(targetIds, allowPinned: true)
}
.disabled(targetIds.isEmpty)
Button("Close Others") {
closeOtherTabs(targetIds)
}
.disabled(tabManager.tabs.count <= 1 || targetIds.count == tabManager.tabs.count)
Button("Close Tabs Below") {
closeTabsBelow(tabId: tab.id)
}
.disabled(index >= tabManager.tabs.count - 1)
Button("Close Tabs Above") {
closeTabsAbove(tabId: tab.id)
}
.disabled(index == 0)
Divider()
Button("Move to Top") {
tabManager.moveTabsToTop(Set(targetIds))
syncSelectionAfterMutation()
}
.disabled(targetIds.isEmpty)
Divider()
Button("Mark as Read") {
markTabsRead(targetIds)
}
.disabled(!hasUnreadNotifications(in: targetIds))
Button("Mark as Unread") {
markTabsUnread(targetIds)
}
.disabled(!hasReadNotifications(in: targetIds))
}
}
private var backgroundColor: Color {
if isActive {
return Color.accentColor
}
if isMultiSelected {
return Color.accentColor.opacity(0.25)
}
return Color.clear
}
private var showsCenteredTopDropIndicator: Bool {
guard draggedTabId != nil, let indicator = dropIndicator else { return false }
if indicator.tabId == tab.id && indicator.edge == .top {
return true
}
guard indicator.edge == .bottom,
let currentIndex = tabManager.tabs.firstIndex(where: { $0.id == tab.id }),
currentIndex > 0
else {
return false
}
return tabManager.tabs[currentIndex - 1].id == indicator.tabId
}
private var accessibilityTitle: String {
"\(tab.title), workspace \(index + 1) of \(tabManager.tabs.count)"
}
private func moveBy(_ delta: Int) {
let targetIndex = index + delta
guard targetIndex >= 0, targetIndex < tabManager.tabs.count else { return }
guard tabManager.reorderWorkspace(tabId: tab.id, toIndex: targetIndex) else { return }
selectedTabIds = [tab.id]
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == tab.id }
tabManager.selectTab(tab)
selection = .tabs
}
private func updateSelection() {
let modifiers = NSEvent.modifierFlags
let isCommand = modifiers.contains(.command)
let isShift = modifiers.contains(.shift)
if isShift, let lastIndex = lastSidebarSelectionIndex {
let lower = min(lastIndex, index)
let upper = max(lastIndex, index)
let rangeIds = tabManager.tabs[lower...upper].map { $0.id }
if isCommand {
selectedTabIds.formUnion(rangeIds)
} else {
selectedTabIds = Set(rangeIds)
}
} else if isCommand {
if selectedTabIds.contains(tab.id) {
selectedTabIds.remove(tab.id)
} else {
selectedTabIds.insert(tab.id)
}
} else {
selectedTabIds = [tab.id]
}
lastSidebarSelectionIndex = index
tabManager.selectTab(tab)
selection = .tabs
}
private func contextTargetIds() -> [UUID] {
let baseIds: Set<UUID> = selectedTabIds.contains(tab.id) ? selectedTabIds : [tab.id]
return tabManager.tabs.compactMap { baseIds.contains($0.id) ? $0.id : nil }
}
private func closeTabs(_ targetIds: [UUID], allowPinned: Bool) {
let idsToClose = targetIds.filter { id in
guard let tab = tabManager.tabs.first(where: { $0.id == id }) else { return false }
return allowPinned || !tab.isPinned
}
for id in idsToClose {
if let tab = tabManager.tabs.first(where: { $0.id == id }) {
tabManager.closeWorkspaceWithConfirmation(tab)
}
}
selectedTabIds.subtract(idsToClose)
syncSelectionAfterMutation()
}
private func closeOtherTabs(_ targetIds: [UUID]) {
let keepIds = Set(targetIds)
let idsToClose = tabManager.tabs.compactMap { keepIds.contains($0.id) ? nil : $0.id }
closeTabs(idsToClose, allowPinned: false)
}
private func closeTabsBelow(tabId: UUID) {
guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }
let idsToClose = tabManager.tabs.suffix(from: anchorIndex + 1).map { $0.id }
closeTabs(idsToClose, allowPinned: false)
}
private func closeTabsAbove(tabId: UUID) {
guard let anchorIndex = tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return }
let idsToClose = tabManager.tabs.prefix(upTo: anchorIndex).map { $0.id }
closeTabs(idsToClose, allowPinned: false)
}
private func markTabsRead(_ targetIds: [UUID]) {
for id in targetIds {
notificationStore.markRead(forTabId: id)
}
}
private func markTabsUnread(_ targetIds: [UUID]) {
for id in targetIds {
notificationStore.markUnread(forTabId: id)
}
}
private func hasUnreadNotifications(in targetIds: [UUID]) -> Bool {
let targetSet = Set(targetIds)
return notificationStore.notifications.contains { targetSet.contains($0.tabId) && !$0.isRead }
}
private func hasReadNotifications(in targetIds: [UUID]) -> Bool {
let targetSet = Set(targetIds)
return notificationStore.notifications.contains { targetSet.contains($0.tabId) && $0.isRead }
}
private func syncSelectionAfterMutation() {
let existingIds = Set(tabManager.tabs.map { $0.id })
selectedTabIds = selectedTabIds.filter { existingIds.contains($0) }
if selectedTabIds.isEmpty, let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
}
if let selectedId = tabManager.selectedTabId {
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
}
}
private var latestNotificationText: String? {
guard let notification = notificationStore.latestNotification(forTabId: tab.id) else { return nil }
let text = notification.body.isEmpty ? notification.title : notification.body
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private var directorySummary: String? {
guard !tab.panels.isEmpty else { return nil }
let home = FileManager.default.homeDirectoryForCurrentUser.path
var seen: Set<String> = []
var entries: [String] = []
for panelId in tab.panels.keys {
let directory = tab.panelDirectories[panelId] ?? tab.currentDirectory
let shortened = shortenPath(directory, home: home)
guard !shortened.isEmpty else { continue }
if seen.insert(shortened).inserted {
entries.append(shortened)
}
}
return entries.isEmpty ? nil : entries.joined(separator: " | ")
}
private func shortenPath(_ path: String, home: String) -> String {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return path }
if trimmed == home {
return "~"
}
if trimmed.hasPrefix(home + "/") {
return "~" + trimmed.dropFirst(home.count)
}
return trimmed
}
private func promptRename() {
let alert = NSAlert()
alert.messageText = "Rename Tab"
alert.informativeText = "Enter a custom name for this tab."
let input = NSTextField(string: tab.customTitle ?? tab.title)
input.placeholderString = "Tab name"
input.frame = NSRect(x: 0, y: 0, width: 240, height: 22)
alert.accessoryView = input
alert.addButton(withTitle: "Rename")
alert.addButton(withTitle: "Cancel")
let alertWindow = alert.window
alertWindow.initialFirstResponder = input
DispatchQueue.main.async {
alertWindow.makeFirstResponder(input)
input.selectText(nil)
}
let response = alert.runModal()
guard response == .alertFirstButtonReturn else { return }
tabManager.setCustomTitle(tabId: tab.id, title: input.stringValue)
}
}
enum SidebarDropEdge {
case top
case bottom
}
struct SidebarDropIndicator {
let tabId: UUID?
let edge: SidebarDropEdge
}
enum SidebarDropPlanner {
static func indicator(
draggedTabId: UUID?,
targetTabId: UUID?,
tabIds: [UUID],
pointerY: CGFloat? = nil,
targetHeight: CGFloat? = nil
) -> SidebarDropIndicator? {
guard tabIds.count > 1, let draggedTabId else { return nil }
guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil }
let insertionPosition: Int
if let targetTabId {
guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil }
let edge: SidebarDropEdge
if let pointerY, let targetHeight {
edge = edgeForPointer(locationY: pointerY, targetHeight: targetHeight)
} else {
edge = preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)
}
insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex
} else {
insertionPosition = tabIds.count
}
let targetIndex = resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count)
guard targetIndex != fromIndex else { return nil }
return indicatorForInsertionPosition(insertionPosition, tabIds: tabIds)
}
static func targetIndex(
draggedTabId: UUID,
targetTabId: UUID?,
indicator: SidebarDropIndicator?,
tabIds: [UUID]
) -> Int? {
guard let fromIndex = tabIds.firstIndex(of: draggedTabId) else { return nil }
let insertionPosition: Int
if let indicator, let indicatorInsertion = insertionPositionForIndicator(indicator, tabIds: tabIds) {
insertionPosition = indicatorInsertion
} else if let targetTabId {
guard let targetTabIndex = tabIds.firstIndex(of: targetTabId) else { return nil }
let edge = (indicator?.tabId == targetTabId)
? (indicator?.edge ?? preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds))
: preferredEdge(fromIndex: fromIndex, targetTabId: targetTabId, tabIds: tabIds)
insertionPosition = (edge == .bottom) ? targetTabIndex + 1 : targetTabIndex
} else {
insertionPosition = tabIds.count
}
return resolvedTargetIndex(from: fromIndex, insertionPosition: insertionPosition, totalCount: tabIds.count)
}
private static func indicatorForInsertionPosition(_ insertionPosition: Int, tabIds: [UUID]) -> SidebarDropIndicator {
let clampedInsertion = max(0, min(insertionPosition, tabIds.count))
if clampedInsertion >= tabIds.count {
return SidebarDropIndicator(tabId: nil, edge: .bottom)
}
return SidebarDropIndicator(tabId: tabIds[clampedInsertion], edge: .top)
}
private static func insertionPositionForIndicator(_ indicator: SidebarDropIndicator, tabIds: [UUID]) -> Int? {
if let tabId = indicator.tabId {
guard let targetTabIndex = tabIds.firstIndex(of: tabId) else { return nil }
return indicator.edge == .bottom ? targetTabIndex + 1 : targetTabIndex
}
return tabIds.count
}
private static func preferredEdge(fromIndex: Int, targetTabId: UUID, tabIds: [UUID]) -> SidebarDropEdge {
guard let targetIndex = tabIds.firstIndex(of: targetTabId) else { return .top }
return fromIndex < targetIndex ? .bottom : .top
}
static func edgeForPointer(locationY: CGFloat, targetHeight: CGFloat) -> SidebarDropEdge {
guard targetHeight > 0 else { return .top }
let clampedY = min(max(locationY, 0), targetHeight)
return clampedY < (targetHeight / 2) ? .top : .bottom
}
private static func resolvedTargetIndex(from sourceIndex: Int, insertionPosition: Int, totalCount: Int) -> Int {
let clampedInsertion = max(0, min(insertionPosition, totalCount))
let adjusted = clampedInsertion > sourceIndex ? clampedInsertion - 1 : clampedInsertion
return max(0, min(adjusted, max(0, totalCount - 1)))
}
}
enum SidebarAutoScrollDirection: Equatable {
case up
case down
}
struct SidebarAutoScrollPlan: Equatable {
let direction: SidebarAutoScrollDirection
let pointsPerTick: CGFloat
}
enum SidebarDragAutoScrollPlanner {
static let edgeInset: CGFloat = 44
static let minStep: CGFloat = 2
static let maxStep: CGFloat = 12
static func plan(
distanceToTop: CGFloat,
distanceToBottom: CGFloat,
edgeInset: CGFloat = SidebarDragAutoScrollPlanner.edgeInset,
minStep: CGFloat = SidebarDragAutoScrollPlanner.minStep,
maxStep: CGFloat = SidebarDragAutoScrollPlanner.maxStep
) -> SidebarAutoScrollPlan? {
guard edgeInset > 0, maxStep >= minStep else { return nil }
if distanceToTop <= edgeInset {
let normalized = max(0, min(1, (edgeInset - distanceToTop) / edgeInset))
let step = minStep + ((maxStep - minStep) * normalized)
return SidebarAutoScrollPlan(direction: .up, pointsPerTick: step)
}
if distanceToBottom <= edgeInset {
let normalized = max(0, min(1, (edgeInset - distanceToBottom) / edgeInset))
let step = minStep + ((maxStep - minStep) * normalized)
return SidebarAutoScrollPlan(direction: .down, pointsPerTick: step)
}
return nil
}
}
@MainActor
private final class SidebarDragAutoScrollController: ObservableObject {
private weak var scrollView: NSScrollView?
private var timer: Timer?
private var activePlan: SidebarAutoScrollPlan?
func attach(scrollView: NSScrollView?) {
self.scrollView = scrollView
}
func updateFromDragLocation() {
guard let scrollView else {
stop()
return
}
guard let plan = plan(for: scrollView) else {
stop()
return
}
activePlan = plan
startTimerIfNeeded()
}
func stop() {
timer?.invalidate()
timer = nil
activePlan = nil
}
private func startTimerIfNeeded() {
guard timer == nil else { return }
let timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
self?.tick()
}
}
self.timer = timer
RunLoop.main.add(timer, forMode: .eventTracking)
}
private func tick() {
guard NSEvent.pressedMouseButtons != 0 else {
stop()
return
}
guard let scrollView else {
stop()
return
}
// AppKit drag/drop autoscroll guidance recommends autoscroll(with:)
// when periodic drag updates are available; use it first.
if applyNativeAutoscroll(to: scrollView) {
activePlan = plan(for: scrollView)
if activePlan == nil {
stop()
}
return
}
activePlan = self.plan(for: scrollView)
guard let plan = activePlan else {
stop()
return
}
_ = apply(plan: plan, to: scrollView)
}
private func applyNativeAutoscroll(to scrollView: NSScrollView) -> Bool {
guard let event = NSApp.currentEvent else { return false }
switch event.type {
case .leftMouseDragged, .rightMouseDragged, .otherMouseDragged:
break
default:
return false
}
let clipView = scrollView.contentView
let didScroll = clipView.autoscroll(with: event)
if didScroll {
scrollView.reflectScrolledClipView(clipView)
}
return didScroll
}
private func distancesToEdges(mousePoint: CGPoint, viewportHeight: CGFloat, isFlipped: Bool) -> (top: CGFloat, bottom: CGFloat) {
if isFlipped {
return (top: mousePoint.y, bottom: viewportHeight - mousePoint.y)
}
return (top: viewportHeight - mousePoint.y, bottom: mousePoint.y)
}
private func planForMousePoint(_ mousePoint: CGPoint, in clipView: NSClipView) -> SidebarAutoScrollPlan? {
let viewportHeight = clipView.bounds.height
guard viewportHeight > 0 else { return nil }
let distances = distancesToEdges(mousePoint: mousePoint, viewportHeight: viewportHeight, isFlipped: clipView.isFlipped)
return SidebarDragAutoScrollPlanner.plan(distanceToTop: distances.top, distanceToBottom: distances.bottom)
}
private func mousePoint(in clipView: NSClipView) -> CGPoint {
let mouseInWindow = clipView.window?.convertPoint(fromScreen: NSEvent.mouseLocation) ?? .zero
return clipView.convert(mouseInWindow, from: nil)
}
private func currentPlan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? {
let clipView = scrollView.contentView
let mouse = mousePoint(in: clipView)
return planForMousePoint(mouse, in: clipView)
}
private func plan(for scrollView: NSScrollView) -> SidebarAutoScrollPlan? {
currentPlan(for: scrollView)
}
private func apply(plan: SidebarAutoScrollPlan, to scrollView: NSScrollView) -> Bool {
guard let documentView = scrollView.documentView else { return false }
let clipView = scrollView.contentView
let maxOriginY = max(0, documentView.bounds.height - clipView.bounds.height)
guard maxOriginY > 0 else { return false }
let directionMultiplier: CGFloat = (plan.direction == .down) ? 1 : -1
let flippedMultiplier: CGFloat = documentView.isFlipped ? 1 : -1
let delta = directionMultiplier * flippedMultiplier * plan.pointsPerTick
let currentY = clipView.bounds.origin.y
let targetY = min(max(currentY + delta, 0), maxOriginY)
guard abs(targetY - currentY) > 0.01 else { return false }
clipView.scroll(to: CGPoint(x: clipView.bounds.origin.x, y: targetY))
scrollView.reflectScrolledClipView(clipView)
return true
}
}
private enum SidebarTabDragPayload {
static let typeIdentifier = UTType.plainText.identifier
private static let prefix = "cmux.sidebar-tab."
static func provider(for tabId: UUID) -> NSItemProvider {
NSItemProvider(object: "\(prefix)\(tabId.uuidString)" as NSString)
}
}
private struct SidebarTabDropDelegate: DropDelegate {
let targetTabId: UUID?
let tabManager: TabManager
@Binding var draggedTabId: UUID?
@Binding var selectedTabIds: Set<UUID>
@Binding var lastSidebarSelectionIndex: Int?
let targetRowHeight: CGFloat?
let dragAutoScrollController: SidebarDragAutoScrollController
@Binding var dropIndicator: SidebarDropIndicator?
func validateDrop(info: DropInfo) -> Bool {
info.hasItemsConforming(to: [SidebarTabDragPayload.typeIdentifier]) && draggedTabId != nil
}
func dropEntered(info: DropInfo) {
dragAutoScrollController.updateFromDragLocation()
updateDropIndicator(for: info)
}
func dropExited(info: DropInfo) {
if dropIndicator?.tabId == targetTabId {
dropIndicator = nil
}
}
func dropUpdated(info: DropInfo) -> DropProposal? {
dragAutoScrollController.updateFromDragLocation()
updateDropIndicator(for: info)
return DropProposal(operation: .move)
}
func performDrop(info: DropInfo) -> Bool {
defer {
draggedTabId = nil
dropIndicator = nil
dragAutoScrollController.stop()
}
guard let draggedTabId else { return false }
guard let fromIndex = tabManager.tabs.firstIndex(where: { $0.id == draggedTabId }) else { return false }
let tabIds = tabManager.tabs.map(\.id)
guard let targetIndex = SidebarDropPlanner.targetIndex(
draggedTabId: draggedTabId,
targetTabId: targetTabId,
indicator: dropIndicator,
tabIds: tabIds
) else {
return false
}
guard fromIndex != targetIndex else {
syncSidebarSelection()
return true
}
_ = tabManager.reorderWorkspace(tabId: draggedTabId, toIndex: targetIndex)
if let selectedId = tabManager.selectedTabId {
selectedTabIds = [selectedId]
syncSidebarSelection(preferredSelectedTabId: selectedId)
} else {
selectedTabIds = []
syncSidebarSelection()
}
return true
}
private func updateDropIndicator(for info: DropInfo) {
let tabIds = tabManager.tabs.map(\.id)
dropIndicator = SidebarDropPlanner.indicator(
draggedTabId: draggedTabId,
targetTabId: targetTabId,
tabIds: tabIds,
pointerY: targetTabId == nil ? nil : info.location.y,
targetHeight: targetRowHeight
)
}
private func syncSidebarSelection(preferredSelectedTabId: UUID? = nil) {
let selectedId = preferredSelectedTabId ?? tabManager.selectedTabId
if let selectedId {
lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId }
} else {
lastSidebarSelectionIndex = nil
}
}
}
private struct MiddleClickCapture: NSViewRepresentable {
let onMiddleClick: () -> Void
func makeNSView(context: Context) -> MiddleClickCaptureView {
let view = MiddleClickCaptureView()
view.onMiddleClick = onMiddleClick
return view
}
func updateNSView(_ nsView: MiddleClickCaptureView, context: Context) {
nsView.onMiddleClick = onMiddleClick
}
}
private final class MiddleClickCaptureView: NSView {
var onMiddleClick: (() -> Void)?
override func hitTest(_ point: NSPoint) -> NSView? {
// Only intercept middle-click so left-click selection and right-click context menus
// continue to hit-test through to SwiftUI/AppKit normally.
guard let event = NSApp.currentEvent,
event.type == .otherMouseDown,
event.buttonNumber == 2 else {
return nil
}
return self
}
override func otherMouseDown(with event: NSEvent) {
guard event.buttonNumber == 2 else {
super.otherMouseDown(with: event)
return
}
onMiddleClick?()
}
}
enum SidebarSelection {
case tabs
case notifications
}
private struct ClearScrollBackground: ViewModifier {
func body(content: Content) -> some View {
if #available(macOS 13.0, *) {
content
.scrollContentBackground(.hidden)
.background(ScrollBackgroundClearer())
} else {
content
.background(ScrollBackgroundClearer())
}
}
}
private struct ScrollBackgroundClearer: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
NSView()
}
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
guard let scrollView = findScrollView(startingAt: nsView) else { return }
// Clear all backgrounds and mark as non-opaque for transparency
scrollView.drawsBackground = false
scrollView.backgroundColor = .clear
scrollView.wantsLayer = true
scrollView.layer?.backgroundColor = NSColor.clear.cgColor
scrollView.layer?.isOpaque = false
scrollView.contentView.drawsBackground = false
scrollView.contentView.backgroundColor = .clear
scrollView.contentView.wantsLayer = true
scrollView.contentView.layer?.backgroundColor = NSColor.clear.cgColor
scrollView.contentView.layer?.isOpaque = false
if let docView = scrollView.documentView {
docView.wantsLayer = true
docView.layer?.backgroundColor = NSColor.clear.cgColor
docView.layer?.isOpaque = false
}
}
}
private func findScrollView(startingAt view: NSView) -> NSScrollView? {
var current: NSView? = view
while let candidate = current {
if let scrollView = candidate as? NSScrollView {
return scrollView
}
current = candidate.superview
}
return nil
}
}
private struct DraggableFolderIcon: View {
let directory: String
var body: some View {
DraggableFolderIconRepresentable(directory: directory)
.frame(width: 16, height: 16)
.help("Drag to open in Finder or another app")
.onTapGesture(count: 2) {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: directory)
}
}
}
private struct DraggableFolderIconRepresentable: NSViewRepresentable {
let directory: String
func makeNSView(context: Context) -> DraggableFolderNSView {
DraggableFolderNSView(directory: directory)
}
func updateNSView(_ nsView: DraggableFolderNSView, context: Context) {
nsView.directory = directory
nsView.updateIcon()
}
}
private final class DraggableFolderNSView: NSView, NSDraggingSource {
var directory: String
private var imageView: NSImageView!
init(directory: String) {
self.directory = directory
super.init(frame: .zero)
setupImageView()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupImageView() {
imageView = NSImageView()
imageView.imageScaling = .scaleProportionallyUpOrDown
imageView.translatesAutoresizingMaskIntoConstraints = false
addSubview(imageView)
NSLayoutConstraint.activate([
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
imageView.topAnchor.constraint(equalTo: topAnchor),
imageView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
updateIcon()
}
func updateIcon() {
let icon = NSWorkspace.shared.icon(forFile: directory)
icon.size = NSSize(width: 16, height: 16)
imageView.image = icon
}
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
return context == .outsideApplication ? [.copy, .link] : .copy
}
override func mouseDown(with event: NSEvent) {
let fileURL = URL(fileURLWithPath: directory)
let draggingItem = NSDraggingItem(pasteboardWriter: fileURL as NSURL)
let iconImage = NSWorkspace.shared.icon(forFile: directory)
iconImage.size = NSSize(width: 32, height: 32)
draggingItem.setDraggingFrame(bounds, contents: iconImage)
beginDraggingSession(with: [draggingItem], event: event, source: self)
}
override func rightMouseDown(with event: NSEvent) {
let menu = buildPathMenu()
// Pop up menu at bottom-left of icon (like native proxy icon)
let menuLocation = NSPoint(x: 0, y: bounds.height)
menu.popUp(positioning: nil, at: menuLocation, in: self)
}
private func buildPathMenu() -> NSMenu {
let menu = NSMenu()
let url = URL(fileURLWithPath: directory).standardized
var pathComponents: [URL] = []
// Build path from current directory up to root
var current = url
while current.path != "/" {
pathComponents.append(current)
current = current.deletingLastPathComponent()
}
pathComponents.append(URL(fileURLWithPath: "/"))
// Add path components (current dir at top, root at bottom - matches native macOS)
for pathURL in pathComponents {
let icon = NSWorkspace.shared.icon(forFile: pathURL.path)
icon.size = NSSize(width: 16, height: 16)
let displayName: String
if pathURL.path == "/" {
// Use the volume name for root
if let volumeName = try? URL(fileURLWithPath: "/").resourceValues(forKeys: [.volumeNameKey]).volumeName {
displayName = volumeName
} else {
displayName = "Macintosh HD"
}
} else {
displayName = FileManager.default.displayName(atPath: pathURL.path)
}
let item = NSMenuItem(title: displayName, action: #selector(openPathComponent(_:)), keyEquivalent: "")
item.target = self
item.image = icon
item.representedObject = pathURL
menu.addItem(item)
}
// Add computer name at the bottom (like native proxy icon)
let computerName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
let computerIcon = NSImage(named: NSImage.computerName) ?? NSImage()
computerIcon.size = NSSize(width: 16, height: 16)
let computerItem = NSMenuItem(title: computerName, action: #selector(openComputer(_:)), keyEquivalent: "")
computerItem.target = self
computerItem.image = computerIcon
menu.addItem(computerItem)
return menu
}
@objc private func openPathComponent(_ sender: NSMenuItem) {
guard let url = sender.representedObject as? URL else { return }
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path)
}
@objc private func openComputer(_ sender: NSMenuItem) {
// Open "Computer" view in Finder (shows all volumes)
NSWorkspace.shared.open(URL(fileURLWithPath: "/", isDirectory: true))
}
}
/// Wrapper view that tries NSGlassEffectView (macOS 26+) when available or requested
private struct SidebarVisualEffectBackground: NSViewRepresentable {
let material: NSVisualEffectView.Material
let blendingMode: NSVisualEffectView.BlendingMode
let state: NSVisualEffectView.State
let opacity: Double
let tintColor: NSColor?
let cornerRadius: CGFloat
let preferLiquidGlass: Bool
init(
material: NSVisualEffectView.Material = .hudWindow,
blendingMode: NSVisualEffectView.BlendingMode = .behindWindow,
state: NSVisualEffectView.State = .active,
opacity: Double = 1.0,
tintColor: NSColor? = nil,
cornerRadius: CGFloat = 0,
preferLiquidGlass: Bool = false
) {
self.material = material
self.blendingMode = blendingMode
self.state = state
self.opacity = opacity
self.tintColor = tintColor
self.cornerRadius = cornerRadius
self.preferLiquidGlass = preferLiquidGlass
}
static var liquidGlassAvailable: Bool {
NSClassFromString("NSGlassEffectView") != nil
}
func makeNSView(context: Context) -> NSView {
// Try NSGlassEffectView if preferred or if we want to test availability
if preferLiquidGlass, let glassClass = NSClassFromString("NSGlassEffectView") as? NSView.Type {
let glass = glassClass.init(frame: .zero)
glass.autoresizingMask = [.width, .height]
glass.wantsLayer = true
return glass
}
// Use NSVisualEffectView
let view = NSVisualEffectView()
view.autoresizingMask = [.width, .height]
view.wantsLayer = true
view.layerContentsRedrawPolicy = .onSetNeedsDisplay
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
// Configure based on view type
if nsView.className == "NSGlassEffectView" {
// NSGlassEffectView configuration via private API
nsView.alphaValue = max(0.0, min(1.0, opacity))
nsView.layer?.cornerRadius = cornerRadius
nsView.layer?.masksToBounds = cornerRadius > 0
// Try to set tint color via private selector
if let color = tintColor {
let selector = NSSelectorFromString("setTintColor:")
if nsView.responds(to: selector) {
nsView.perform(selector, with: color)
}
}
} else if let visualEffect = nsView as? NSVisualEffectView {
// NSVisualEffectView configuration
visualEffect.material = material
visualEffect.blendingMode = blendingMode
visualEffect.state = state
visualEffect.alphaValue = max(0.0, min(1.0, opacity))
visualEffect.layer?.cornerRadius = cornerRadius
visualEffect.layer?.masksToBounds = cornerRadius > 0
visualEffect.needsDisplay = true
}
}
}
/// Reads the leading inset required to clear traffic lights + left titlebar accessories.
private struct TitlebarLeadingInsetReader: NSViewRepresentable {
@Binding var inset: CGFloat
func makeNSView(context: Context) -> NSView {
let view = NSView()
view.setFrameSize(.zero)
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
guard let window = nsView.window else { return }
// Start past the traffic lights
var leading: CGFloat = 78
// Add width of all left-aligned titlebar accessories
for accessory in window.titlebarAccessoryViewControllers
where accessory.layoutAttribute == .leading || accessory.layoutAttribute == .left {
leading += accessory.view.frame.width
}
leading += 16
if leading != inset {
inset = leading
}
}
}
}
private struct SidebarBackdrop: View {
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.18
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000"
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0
var body: some View {
let materialOption = SidebarMaterialOption(rawValue: sidebarMaterial)
let blendingMode = SidebarBlendModeOption(rawValue: sidebarBlendMode)?.mode ?? .behindWindow
let state = SidebarStateOption(rawValue: sidebarState)?.state ?? .active
let tintColor = (NSColor(hex: sidebarTintHex) ?? .black).withAlphaComponent(sidebarTintOpacity)
let cornerRadius = CGFloat(max(0, sidebarCornerRadius))
let useLiquidGlass = materialOption?.usesLiquidGlass ?? false
let useWindowLevelGlass = useLiquidGlass && blendingMode == .behindWindow
return ZStack {
if let material = materialOption?.material {
// When using liquidGlass + behindWindow, window handles glass + tint
// Sidebar is fully transparent
if !useWindowLevelGlass {
SidebarVisualEffectBackground(
material: material,
blendingMode: blendingMode,
state: state,
opacity: sidebarBlurOpacity,
tintColor: tintColor,
cornerRadius: cornerRadius,
preferLiquidGlass: useLiquidGlass
)
// Tint overlay for NSVisualEffectView fallback
if !useLiquidGlass {
Color(nsColor: tintColor)
}
}
}
// When material is none or useWindowLevelGlass, render nothing
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
}
enum SidebarMaterialOption: String, CaseIterable, Identifiable {
case none
case liquidGlass // macOS 26+ NSGlassEffectView
case sidebar
case hudWindow
case menu
case popover
case underWindowBackground
case windowBackground
case contentBackground
case fullScreenUI
case sheet
case headerView
case toolTip
var id: String { rawValue }
var title: String {
switch self {
case .none: return "None"
case .liquidGlass: return "Liquid Glass (macOS 26+)"
case .sidebar: return "Sidebar"
case .hudWindow: return "HUD Window"
case .menu: return "Menu"
case .popover: return "Popover"
case .underWindowBackground: return "Under Window"
case .windowBackground: return "Window Background"
case .contentBackground: return "Content Background"
case .fullScreenUI: return "Full Screen UI"
case .sheet: return "Sheet"
case .headerView: return "Header View"
case .toolTip: return "Tool Tip"
}
}
/// Returns true if this option should use NSGlassEffectView (macOS 26+)
var usesLiquidGlass: Bool {
self == .liquidGlass
}
var material: NSVisualEffectView.Material? {
switch self {
case .none: return nil
case .liquidGlass: return .underWindowBackground // Fallback material
case .sidebar: return .sidebar
case .hudWindow: return .hudWindow
case .menu: return .menu
case .popover: return .popover
case .underWindowBackground: return .underWindowBackground
case .windowBackground: return .windowBackground
case .contentBackground: return .contentBackground
case .fullScreenUI: return .fullScreenUI
case .sheet: return .sheet
case .headerView: return .headerView
case .toolTip: return .toolTip
}
}
}
enum SidebarBlendModeOption: String, CaseIterable, Identifiable {
case behindWindow
case withinWindow
var id: String { rawValue }
var title: String {
switch self {
case .behindWindow: return "Behind Window"
case .withinWindow: return "Within Window"
}
}
var mode: NSVisualEffectView.BlendingMode {
switch self {
case .behindWindow: return .behindWindow
case .withinWindow: return .withinWindow
}
}
}
enum SidebarStateOption: String, CaseIterable, Identifiable {
case active
case inactive
case followWindow
var id: String { rawValue }
var title: String {
switch self {
case .active: return "Active"
case .inactive: return "Inactive"
case .followWindow: return "Follow Window"
}
}
var state: NSVisualEffectView.State {
switch self {
case .active: return .active
case .inactive: return .inactive
case .followWindow: return .followsWindowActiveState
}
}
}
enum SidebarPresetOption: String, CaseIterable, Identifiable {
case nativeSidebar
case glassBehind
case softBlur
case popoverGlass
case hudGlass
case underWindow
var id: String { rawValue }
var title: String {
switch self {
case .nativeSidebar: return "Native Sidebar"
case .glassBehind: return "Raycast Gray"
case .softBlur: return "Soft Blur"
case .popoverGlass: return "Popover Glass"
case .hudGlass: return "HUD Glass"
case .underWindow: return "Under Window"
}
}
var material: SidebarMaterialOption {
switch self {
case .nativeSidebar: return .sidebar
case .glassBehind: return .sidebar
case .softBlur: return .sidebar
case .popoverGlass: return .popover
case .hudGlass: return .hudWindow
case .underWindow: return .underWindowBackground
}
}
var blendMode: SidebarBlendModeOption {
switch self {
case .nativeSidebar: return .withinWindow
case .glassBehind: return .behindWindow
case .softBlur: return .behindWindow
case .popoverGlass: return .behindWindow
case .hudGlass: return .withinWindow
case .underWindow: return .withinWindow
}
}
var state: SidebarStateOption {
switch self {
case .nativeSidebar: return .followWindow
case .glassBehind: return .active
case .softBlur: return .active
case .popoverGlass: return .active
case .hudGlass: return .active
case .underWindow: return .followWindow
}
}
var tintHex: String {
switch self {
case .nativeSidebar: return "#000000"
case .glassBehind: return "#000000"
case .softBlur: return "#000000"
case .popoverGlass: return "#000000"
case .hudGlass: return "#000000"
case .underWindow: return "#000000"
}
}
var tintOpacity: Double {
switch self {
case .nativeSidebar: return 0.18
case .glassBehind: return 0.36
case .softBlur: return 0.28
case .popoverGlass: return 0.10
case .hudGlass: return 0.62
case .underWindow: return 0.14
}
}
var cornerRadius: Double {
switch self {
case .nativeSidebar: return 0.0
case .glassBehind: return 0.0
case .softBlur: return 0.0
case .popoverGlass: return 10.0
case .hudGlass: return 10.0
case .underWindow: return 6.0
}
}
var blurOpacity: Double {
switch self {
case .nativeSidebar: return 1.0
case .glassBehind: return 0.6
case .softBlur: return 0.45
case .popoverGlass: return 0.9
case .hudGlass: return 0.98
case .underWindow: return 0.9
}
}
}
extension NSColor {
func hexString() -> String {
let color = usingColorSpace(.sRGB) ?? self
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
color.getRed(&red, green: &green, blue: &blue, alpha: &alpha)
return String(
format: "#%02X%02X%02X",
min(255, max(0, Int(red * 255))),
min(255, max(0, Int(green * 255))),
min(255, max(0, Int(blue * 255)))
)
}
}