WindowGlassEffect.apply() replaced the window's contentView with the glass/blur view on all macOS versions. On macOS 13-15, swapping the contentView on a window with fullSizeContentView + transparent titlebar breaks the titlebar view hierarchy, causing traffic light buttons to disappear behind the content. Split into two paths: - macOS 26+ (NSGlassEffectView): full contentView replacement (safe) - macOS 13-15: insert NSVisualEffectView as a background subview of the existing contentView, preserving the titlebar hierarchy Also fix the custom titlebar overlay overlapping the traffic lights and left titlebar accessories (sidebar/bell/+ buttons) when the sidebar is hidden. Use TitlebarLeadingInsetReader to dynamically measure the actual width of traffic lights + left accessories and apply it as leading padding.
1385 lines
51 KiB
Swift
1385 lines
51 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import ObjectiveC
|
|
|
|
/// 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 contentView = 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 = contentView.bounds
|
|
|
|
// macOS 26+: Use NSGlassEffectView as the new contentView (full replacement is safe)
|
|
if let glassClass = NSClassFromString("NSGlassEffectView") as? NSVisualEffectView.Type {
|
|
let glassView = glassClass.init(frame: bounds)
|
|
glassView.wantsLayer = true
|
|
glassView.layer?.cornerRadius = 0
|
|
glassView.autoresizingMask = [.width, .height]
|
|
|
|
if let color = tintColor {
|
|
let selector = NSSelectorFromString("setTintColor:")
|
|
if glassView.responds(to: selector) {
|
|
glassView.perform(selector, with: color)
|
|
}
|
|
}
|
|
|
|
// Replace contentView — safe on macOS 26+ where traffic lights composite above
|
|
window.contentView = glassView
|
|
|
|
contentView.translatesAutoresizingMaskIntoConstraints = false
|
|
contentView.wantsLayer = true
|
|
contentView.layer?.backgroundColor = NSColor.clear.cgColor
|
|
glassView.addSubview(contentView)
|
|
|
|
NSLayoutConstraint.activate([
|
|
contentView.topAnchor.constraint(equalTo: glassView.topAnchor),
|
|
contentView.bottomAnchor.constraint(equalTo: glassView.bottomAnchor),
|
|
contentView.leadingAnchor.constraint(equalTo: glassView.leadingAnchor),
|
|
contentView.trailingAnchor.constraint(equalTo: glassView.trailingAnchor)
|
|
])
|
|
|
|
objc_setAssociatedObject(window, &glassViewKey, glassView, .OBJC_ASSOCIATION_RETAIN)
|
|
return
|
|
}
|
|
|
|
// Older macOS: insert blur as a background subview instead of replacing contentView.
|
|
// Replacing contentView on macOS 13-15 breaks traffic light rendering when the
|
|
// window uses fullSizeContentView + titlebarAppearsTransparent.
|
|
let blurView = NSVisualEffectView(frame: bounds)
|
|
blurView.blendingMode = .behindWindow
|
|
blurView.material = .hudWindow
|
|
blurView.state = .active
|
|
blurView.wantsLayer = true
|
|
blurView.autoresizingMask = [.width, .height]
|
|
|
|
contentView.addSubview(blurView, positioned: .below, relativeTo: contentView.subviews.first)
|
|
|
|
// Tint overlay on top of blur, still behind content
|
|
if let color = tintColor {
|
|
let tintOverlay = NSView(frame: bounds)
|
|
tintOverlay.autoresizingMask = [.width, .height]
|
|
tintOverlay.wantsLayer = true
|
|
tintOverlay.layer?.backgroundColor = color.cgColor
|
|
contentView.addSubview(tintOverlay, positioned: .above, relativeTo: blurView)
|
|
objc_setAssociatedObject(window, &tintOverlayKey, tintOverlay, .OBJC_ASSOCIATION_RETAIN)
|
|
}
|
|
|
|
objc_setAssociatedObject(window, &glassViewKey, blurView, .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
|
|
@EnvironmentObject var tabManager: TabManager
|
|
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
|
@EnvironmentObject var sidebarState: SidebarState
|
|
@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 sidebarSelection: SidebarSelection = .tabs
|
|
@State private var selectedTabIds: Set<UUID> = []
|
|
@State private var lastSidebarSelectionIndex: Int? = nil
|
|
@State private var titlebarText: String = ""
|
|
|
|
private var sidebarView: some View {
|
|
VerticalTabsSidebar(
|
|
selection: $sidebarSelection,
|
|
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 titlebar
|
|
private let titlebarPadding: CGFloat = 28
|
|
|
|
private var terminalContent: some View {
|
|
ZStack {
|
|
ZStack {
|
|
ForEach(tabManager.tabs) { tab in
|
|
let isActive = tabManager.selectedTabId == tab.id
|
|
TerminalSplitTreeView(tab: tab, isTabActive: isActive)
|
|
.opacity(isActive ? 1 : 0)
|
|
.allowsHitTesting(isActive)
|
|
}
|
|
}
|
|
.opacity(sidebarSelection == .tabs ? 1 : 0)
|
|
.allowsHitTesting(sidebarSelection == .tabs)
|
|
|
|
NotificationsPage(selection: $sidebarSelection)
|
|
.opacity(sidebarSelection == .notifications ? 1 : 0)
|
|
.allowsHitTesting(sidebarSelection == .notifications)
|
|
}
|
|
.padding(.top, titlebarPadding)
|
|
.overlay(alignment: .top) {
|
|
// Titlebar with background - only over terminal content, not sidebar
|
|
customTitlebar
|
|
.background(Color(nsColor: GhosttyApp.shared.defaultBackgroundColor))
|
|
}
|
|
}
|
|
|
|
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.behindWindow.rawValue
|
|
|
|
// Background glass settings
|
|
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
|
|
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.05
|
|
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = true
|
|
|
|
@State private var titlebarLeadingInset: CGFloat = 12
|
|
|
|
private var customTitlebar: some View {
|
|
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(.secondary)
|
|
.lineLimit(1)
|
|
|
|
Spacer()
|
|
}
|
|
.frame(height: 28)
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.top, 2)
|
|
.padding(.leading, sidebarState.isVisible ? 12 : titlebarLeadingInset)
|
|
.padding(.trailing, 8)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture(count: 2) {
|
|
NSApp.keyWindow?.zoom(nil)
|
|
}
|
|
.background(TitlebarLeadingInsetReader(inset: $titlebarLeadingInset))
|
|
}
|
|
|
|
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 surface's directory if available
|
|
if let focusedSurfaceId = tab.focusedSurfaceId,
|
|
let surfaceDir = tab.surfaceDirectories[focusedSurfaceId] {
|
|
let trimmed = surfaceDir.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
|
|
sidebarSelection = .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("cmux.main")
|
|
window.titlebarAppearsTransparent = true
|
|
window.styleMask.insert(.fullSizeContentView)
|
|
// For behindWindow blur to work, window must be non-opaque with transparent content view
|
|
if sidebarBlendMode == SidebarBlendModeOption.behindWindow.rawValue && bgGlassEnabled {
|
|
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)
|
|
})
|
|
}
|
|
|
|
private func addTab() {
|
|
tabManager.addTab()
|
|
sidebarSelection = .tabs
|
|
}
|
|
|
|
private func updateWindowGlassTint() {
|
|
// Find main window by identifier (keyWindow might be the debug panel)
|
|
guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "cmux.main" }) else { return }
|
|
let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity)
|
|
WindowGlassEffect.updateTint(to: window, color: tintColor)
|
|
}
|
|
}
|
|
|
|
struct VerticalTabsSidebar: View {
|
|
@EnvironmentObject var tabManager: TabManager
|
|
@Binding var selection: SidebarSelection
|
|
@Binding var selectedTabIds: Set<UUID>
|
|
@Binding var lastSidebarSelectionIndex: Int?
|
|
|
|
/// Space at top of sidebar for traffic light buttons
|
|
private let trafficLightPadding: CGFloat = 28
|
|
|
|
var body: some View {
|
|
GeometryReader { proxy in
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
// Space for traffic lights
|
|
Spacer()
|
|
.frame(height: trafficLightPadding)
|
|
|
|
LazyVStack(spacing: 2) {
|
|
ForEach(Array(tabManager.tabs.enumerated()), id: \.element.id) { index, tab in
|
|
TabItemView(
|
|
tab: tab,
|
|
index: index,
|
|
selection: $selection,
|
|
selectedTabIds: $selectedTabIds,
|
|
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
|
|
)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
|
|
SidebarEmptyArea(
|
|
selection: $selection,
|
|
selectedTabIds: $selectedTabIds,
|
|
lastSidebarSelectionIndex: $lastSidebarSelectionIndex
|
|
)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.frame(minHeight: proxy.size.height, alignment: .top)
|
|
}
|
|
.background(Color.clear)
|
|
.modifier(ClearScrollBackground())
|
|
.accessibilityIdentifier("Sidebar")
|
|
}
|
|
.ignoresSafeArea()
|
|
.background(SidebarBackdrop().ignoresSafeArea())
|
|
}
|
|
}
|
|
|
|
private struct SidebarFramePreferenceKey: PreferenceKey {
|
|
static var defaultValue: CGRect = .zero
|
|
|
|
static func reduce(value: inout CGRect, nextValue: () -> CGRect) {
|
|
value = nextValue()
|
|
}
|
|
}
|
|
|
|
private struct SidebarEmptyArea: View {
|
|
@EnvironmentObject var tabManager: TabManager
|
|
@Binding var selection: SidebarSelection
|
|
@Binding var selectedTabIds: Set<UUID>
|
|
@Binding var lastSidebarSelectionIndex: Int?
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TabItemView: View {
|
|
@EnvironmentObject var tabManager: TabManager
|
|
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
|
@ObservedObject var tab: Tab
|
|
let index: Int
|
|
@Binding var selection: SidebarSelection
|
|
@Binding var selectedTabIds: Set<UUID>
|
|
@Binding var lastSidebarSelectionIndex: Int?
|
|
@State private var isHovering = false
|
|
|
|
var isActive: Bool {
|
|
tabManager.selectedTabId == tab.id
|
|
}
|
|
|
|
var isMultiSelected: Bool {
|
|
selectedTabIds.contains(tab.id)
|
|
}
|
|
|
|
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))
|
|
.foregroundColor(isActive ? .white : .primary)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
|
|
Spacer()
|
|
|
|
Button(action: { tabManager.closeTab(tab) }) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 9, weight: .medium))
|
|
.foregroundColor(isActive ? .white.opacity(0.7) : .secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.frame(width: 16, height: 16)
|
|
.opacity((isHovering && tabManager.tabs.count > 1) ? 1 : 0)
|
|
.allowsHitTesting(isHovering && tabManager.tabs.count > 1)
|
|
}
|
|
|
|
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)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
updateSelection()
|
|
}
|
|
.onHover { hovering in
|
|
isHovering = hovering
|
|
}
|
|
.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("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 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.closeTab(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 let root = tab.splitTree.root else { return nil }
|
|
let surfaces = root.leaves()
|
|
guard !surfaces.isEmpty else { return nil }
|
|
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
|
var seen: Set<String> = []
|
|
var entries: [String] = []
|
|
for surface in surfaces {
|
|
let directory = tab.surfaceDirectories[surface.id] ?? 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 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.54
|
|
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#101010"
|
|
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
|
|
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.behindWindow.rawValue
|
|
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
|
|
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
|
|
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 0.79
|
|
|
|
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)))
|
|
)
|
|
}
|
|
}
|