cmux/Sources/ContentView.swift
Lawrence Chen 7d6f33c143
Sidebar status as text + detect git HEAD changes instantly (#30)
* Sidebar status as text + detect git HEAD changes instantly

- Replace sidebar status pills with plain text + show more/less toggle
  for a cleaner, more readable sidebar layout
- Watch .git/HEAD mtime in zsh precmd to detect branch changes from
  aliases (gco), tools (gh pr checkout), etc. without waiting for the
  3s polling interval
- Fix NSImage shared instance mutation in DraggableFolderNSView by
  copying before resizing to prevent layout side-effects
- Fix set_status --tab flag being swallowed by -- stop token via
  new parseOptionsNoStop parser
- Update sidebar test to cover alias-based branch switching

* Append status text to notification body automatically

When creating notifications, include the tab's current status entries
in the notification body so users see context (e.g. git branch, ports)
alongside the notification message.

* Add screenshot to README
2026-02-09 14:18:33 -08:00

1564 lines
59 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+: Insert NSGlassEffectView as a background subview (never replace
// window.contentView reparenting the SwiftUI hosting view causes blank content).
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)
}
}
contentView.addSubview(glassView, positioned: .below, relativeTo: contentView.subviews.first)
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
}
}
// Allow a wider sidebar so long paths and metadata aren't constantly truncated.
let nextWidth = max(186, min(640, 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)
// Background glass: skip on macOS 26+ where NSGlassEffectView causes blank SwiftUI content.
// The transparency setup (non-opaque window + clear subview backgrounds) breaks rendering.
if sidebarBlendMode == SidebarBlendModeOption.behindWindow.rawValue && bgGlassEnabled
&& !WindowGlassEffect.isAvailable {
window.isOpaque = false
window.backgroundColor = .clear
if let contentView = window.contentView {
contentView.wantsLayer = true
contentView.layer?.backgroundColor = NSColor.clear.cgColor
contentView.layer?.isOpaque = false
for subview in contentView.subviews {
subview.wantsLayer = true
subview.layer?.backgroundColor = NSColor.clear.cgColor
subview.layer?.isOpaque = false
}
}
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)
}
@AppStorage("sidebarShowGitBranch") private var sidebarShowGitBranch = true
@AppStorage("sidebarShowGitBranchIcon") private var sidebarShowGitBranchIcon = false
@AppStorage("sidebarShowPorts") private var sidebarShowPorts = true
@AppStorage("sidebarShowLog") private var sidebarShowLog = true
@AppStorage("sidebarShowProgress") private var sidebarShowProgress = true
@AppStorage("sidebarShowStatusPills") private var sidebarShowStatusPills = true
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 sidebarShowStatusPills, !tab.statusEntries.isEmpty {
SidebarStatusPillsRow(
entries: tab.statusEntries.values.sorted(by: { (lhs, rhs) in
if lhs.timestamp != rhs.timestamp { return lhs.timestamp > rhs.timestamp }
return lhs.key < rhs.key
}),
isActive: isActive,
onFocus: { updateSelection() }
)
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Latest log entry
if sidebarShowLog, let latestLog = tab.logEntries.last {
HStack(spacing: 4) {
Image(systemName: logLevelIcon(latestLog.level))
.font(.system(size: 8))
.foregroundColor(logLevelColor(latestLog.level, isActive: isActive))
Text(latestLog.message)
.font(.system(size: 10))
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
.lineLimit(1)
.truncationMode(.tail)
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Progress bar
if sidebarShowProgress, let progress = tab.progress {
VStack(alignment: .leading, spacing: 2) {
GeometryReader { geo in
ZStack(alignment: .leading) {
Capsule()
.fill(isActive ? Color.white.opacity(0.15) : Color.secondary.opacity(0.2))
Capsule()
.fill(isActive ? Color.white.opacity(0.8) : Color.accentColor)
.frame(width: max(0, geo.size.width * CGFloat(progress.value)))
}
}
.frame(height: 3)
if let label = progress.label {
Text(label)
.font(.system(size: 9))
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
.lineLimit(1)
}
}
.transition(.opacity.combined(with: .move(edge: .top)))
}
// Branch + directory row
if let dirRow = branchDirectoryRow {
HStack(spacing: 3) {
if sidebarShowGitBranch && tab.gitBranch != nil && sidebarShowGitBranchIcon {
Image(systemName: "arrow.triangle.branch")
.font(.system(size: 9))
.foregroundColor(isActive ? .white.opacity(0.6) : .secondary)
}
Text(dirRow)
.font(.system(size: 10, design: .monospaced))
.foregroundColor(isActive ? .white.opacity(0.75) : .secondary)
.lineLimit(1)
.truncationMode(.tail)
}
}
}
.animation(.easeInOut(duration: 0.2), value: tab.logEntries.count)
.animation(.easeInOut(duration: 0.2), value: tab.progress != nil)
.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 branchDirectoryRow: String? {
var parts: [String] = []
// Git branch (if enabled and available)
if sidebarShowGitBranch, let git = tab.gitBranch {
let dirty = git.isDirty ? "*" : ""
parts.append("\(git.branch)\(dirty)")
}
// Directory summary
if let dirs = directorySummaryText {
parts.append(dirs)
}
// Ports (if enabled and available)
if sidebarShowPorts, !tab.listeningPorts.isEmpty {
let portsStr = tab.listeningPorts.map { ":\($0)" }.joined(separator: ",")
parts.append(portsStr)
}
let result = parts.joined(separator: " · ")
return result.isEmpty ? nil : result
}
private var directorySummaryText: 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 logLevelIcon(_ level: SidebarLogLevel) -> String {
switch level {
case .info: return "circle.fill"
case .progress: return "arrowtriangle.right.fill"
case .success: return "checkmark.circle.fill"
case .warning: return "exclamationmark.triangle.fill"
case .error: return "xmark.circle.fill"
}
}
private func logLevelColor(_ level: SidebarLogLevel, isActive: Bool) -> Color {
if isActive {
switch level {
case .info: return .white.opacity(0.5)
case .progress: return .white.opacity(0.8)
case .success: return .white.opacity(0.9)
case .warning: return .white.opacity(0.9)
case .error: return .white.opacity(0.9)
}
}
switch level {
case .info: return .secondary
case .progress: return .blue
case .success: return .green
case .warning: return .orange
case .error: return .red
}
}
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)
}
}
private struct SidebarStatusPillsRow: View {
// Renamed/replaced: we now render status as normal text with an optional expand/collapse.
// Kept as a separate view for minimal churn in call sites.
let entries: [SidebarStatusEntry]
let isActive: Bool
let onFocus: () -> Void
@State private var isExpanded: Bool = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(statusText)
.font(.system(size: 10))
.foregroundColor(isActive ? .white.opacity(0.8) : .secondary)
.lineLimit(isExpanded ? nil : 3)
.truncationMode(.tail)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
.onTapGesture {
onFocus()
guard shouldShowToggle else { return }
withAnimation(.easeInOut(duration: 0.15)) {
isExpanded.toggle()
}
}
if shouldShowToggle {
Button(isExpanded ? "Show less" : "Show more") {
onFocus()
withAnimation(.easeInOut(duration: 0.15)) {
isExpanded.toggle()
}
}
.buttonStyle(.plain)
.font(.system(size: 10, weight: .semibold))
.foregroundColor(isActive ? .white.opacity(0.65) : .secondary.opacity(0.9))
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.help(statusText)
}
private var statusText: String {
entries
.map { entry in
// Render like notification text: show the status contents only.
// If the value is empty, fall back to the key so the line isn't blank.
let value = entry.value.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty { return value }
return entry.key
}
.joined(separator: "\n")
}
private var shouldShowToggle: Bool {
// We can't reliably measure truncation in SwiftUI without extra layout plumbing.
// Heuristic: show toggle when there are multiple entries or the text is long enough
// that it likely wraps past 3 lines in the sidebar.
entries.count > 1 || statusText.count > 120
}
}
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!
private static let iconSide: CGFloat = 16
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()
}
override var intrinsicContentSize: NSSize {
NSSize(width: Self.iconSide, height: Self.iconSide)
}
func updateIcon() {
// NSWorkspace may return cached/shared NSImage instances. Never mutate the shared image size,
// since other callsites (e.g. dragging preview) may resize it and inadvertently affect layout.
let icon = (NSWorkspace.shared.icon(forFile: directory).copy() as? NSImage) ?? NSImage()
icon.size = NSSize(width: Self.iconSide, height: Self.iconSide)
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).copy() as? NSImage) ?? NSImage()
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).copy() as? NSImage) ?? NSImage()
icon.size = NSSize(width: Self.iconSide, height: Self.iconSide)
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)?.copy() as? NSImage) ?? NSImage()
computerIcon.size = NSSize(width: Self.iconSide, height: Self.iconSide)
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)))
)
}
}