diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 4cc93b23..3fffe427 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,5 +1,114 @@ 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 originalContentView = window.contentView else { return } + + // Check if we already applied glass (avoid re-wrapping) + if let existingGlass = objc_getAssociatedObject(window, &glassViewKey) as? NSView { + // Already applied, just update the tint + updateTint(on: existingGlass, color: tintColor, window: window) + return + } + + let bounds = originalContentView.bounds + + // Create the glass/blur view + let glassView: NSVisualEffectView + + // Try NSGlassEffectView first (macOS 26 Tahoe+) + if let glassClass = NSClassFromString("NSGlassEffectView") as? NSVisualEffectView.Type { + glassView = glassClass.init(frame: bounds) + glassView.wantsLayer = true + glassView.layer?.cornerRadius = 0 + + // Apply tint color via private API + if let color = tintColor { + let selector = NSSelectorFromString("setTintColor:") + if glassView.responds(to: selector) { + glassView.perform(selector, with: color) + } + } + } else { + // Fallback to NSVisualEffectView + glassView = NSVisualEffectView(frame: bounds) + glassView.blendingMode = .behindWindow + glassView.material = .hudWindow + glassView.state = .active + glassView.wantsLayer = true + } + + glassView.autoresizingMask = [.width, .height] + + // Make glass view the new contentView, add original content on top + window.contentView = glassView + + // Re-add the original SwiftUI hosting view on top of the glass, filling entire area + originalContentView.translatesAutoresizingMaskIntoConstraints = false + originalContentView.wantsLayer = true + originalContentView.layer?.backgroundColor = NSColor.clear.cgColor + glassView.addSubview(originalContentView) + + // Pin to all edges + NSLayoutConstraint.activate([ + originalContentView.topAnchor.constraint(equalTo: glassView.topAnchor), + originalContentView.bottomAnchor.constraint(equalTo: glassView.bottomAnchor), + originalContentView.leadingAnchor.constraint(equalTo: glassView.leadingAnchor), + originalContentView.trailingAnchor.constraint(equalTo: glassView.trailingAnchor) + ]) + + // Add tint overlay between glass and content (for fallback) + if tintColor != nil, NSClassFromString("NSGlassEffectView") == nil { + let tintOverlay = NSView(frame: bounds) + tintOverlay.autoresizingMask = [.width, .height] + tintOverlay.wantsLayer = true + tintOverlay.layer?.backgroundColor = tintColor!.cgColor + glassView.addSubview(tintOverlay, positioned: .below, relativeTo: originalContentView) + objc_setAssociatedObject(window, &tintOverlayKey, tintOverlay, .OBJC_ASSOCIATION_RETAIN) + } + + // Store reference + objc_setAssociatedObject(window, &glassViewKey, glassView, .OBJC_ASSOCIATION_RETAIN) + } + + /// Update the tint color on an existing glass effect + static func updateTint(to window: NSWindow, color: NSColor?) { + guard let glassView = objc_getAssociatedObject(window, &glassViewKey) as? NSView else { return } + updateTint(on: glassView, color: color, window: window) + } + + private static func updateTint(on glassView: NSView, color: NSColor?, window: NSWindow) { + // For NSGlassEffectView, use setTintColor: + if glassView.className == "NSGlassEffectView" { + let selector = NSSelectorFromString("setTintColor:") + if glassView.responds(to: selector) { + glassView.perform(selector, with: color) + } + } else { + // For NSVisualEffectView fallback, update the tint overlay + if let tintOverlay = objc_getAssociatedObject(window, &tintOverlayKey) as? NSView { + tintOverlay.layer?.backgroundColor = color?.cgColor + } + } + } + + static func remove(from window: NSWindow) { + // Note: Removing would require restoring original contentView structure + // For now, just clear the reference + objc_setAssociatedObject(window, &glassViewKey, nil, .OBJC_ASSOCIATION_RETAIN) + objc_setAssociatedObject(window, &tintOverlayKey, nil, .OBJC_ASSOCIATION_RETAIN) + } +} final class SidebarState: ObservableObject { @Published var isVisible: Bool = true @@ -22,88 +131,180 @@ struct ContentView: View { @State private var sidebarSelection: SidebarSelection = .tabs @State private var selectedTabIds: Set = [] @State private var lastSidebarSelectionIndex: Int? = nil + @State private var titlebarText: String = "" - var body: some View { - HStack(spacing: 0) { - if sidebarState.isVisible { - 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 { + 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 } - } else if isResizerHovering { - if !isResizerDragging { - NSCursor.pop() - isResizerHovering = false - } + } + let nextWidth = max(186, min(360, value.location.x - sidebarMinX + sidebarHandleWidth / 2)) + withTransaction(Transaction(animation: nil)) { + sidebarWidth = nextWidth } } - .onDisappear { - if isResizerHovering || isResizerDragging { - NSCursor.pop() - isResizerHovering = false + .onEnded { _ in + if isResizerDragging { isResizerDragging = false + if !isResizerHovering { + NSCursor.pop() + } } } - .gesture( - DragGesture(minimumDistance: 0, coordinateSpace: .global) - .onChanged { value in - if !isResizerDragging { - isResizerDragging = true - if !isResizerHovering { - NSCursor.resizeLeftRight.push() - isResizerHovering = true - } - } - let nextWidth = max(140, 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) - // Terminal Content - use ZStack to keep all surfaces alive - ZStack { - ZStack { - ForEach(tabManager.tabs) { tab in - let isActive = tabManager.selectedTabId == tab.id - TerminalSplitTreeView(tab: tab, isTabActive: isActive) - .opacity(isActive ? 1 : 0) - .allowsHitTesting(isActive) + 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 + + 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, 12) + .padding(.trailing, 8) + .contentShape(Rectangle()) + .onTapGesture(count: 2) { + NSApp.keyWindow?.zoom(nil) + } + } + + 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 } } - .opacity(sidebarSelection == .tabs ? 1 : 0) - .allowsHitTesting(sidebarSelection == .tabs) - - NotificationsPage(selection: $sidebarSelection) - .opacity(sidebarSelection == .notifications ? 1 : 0) - .allowsHitTesting(sidebarSelection == .notifications) + } else { + // Standard HStack mode for behindWindow blur + HStack(spacing: 0) { + if sidebarState.isVisible { + sidebarView + } + terminalContent + } } } .frame(minWidth: 800, minHeight: 600) @@ -114,6 +315,7 @@ struct ContentView: View { selectedTabIds = [selectedId] lastSidebarSelectionIndex = tabManager.tabs.firstIndex { $0.id == selectedId } } + updateTitlebarText() } .onChange(of: tabManager.selectedTabId) { newValue in tabManager.applyWindowBackgroundForSelectedTab() @@ -122,9 +324,21 @@ struct ContentView: View { 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 }) @@ -143,8 +357,37 @@ struct ContentView: View { .onPreferenceChange(SidebarFramePreferenceKey.self) { frame in sidebarMinX = frame.minX } - .background(WindowAccessor { window in + .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) }) @@ -155,6 +398,12 @@ struct ContentView: View { 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 { @@ -163,10 +412,17 @@ struct VerticalTabsSidebar: View { @Binding var selectedTabIds: Set @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( @@ -189,9 +445,12 @@ struct VerticalTabsSidebar: View { } .frame(minHeight: proxy.size.height, alignment: .top) } + .background(Color.clear) + .modifier(ClearScrollBackground()) .accessibilityIdentifier("Sidebar") } - .background(Color(nsColor: .controlBackgroundColor)) + .ignoresSafeArea() + .background(SidebarBackdrop().ignoresSafeArea()) } } @@ -257,6 +516,12 @@ struct TabItemView: View { .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) @@ -272,8 +537,8 @@ struct TabItemView: View { } .buttonStyle(.plain) .frame(width: 16, height: 16) - .opacity((isHovering || isActive || isMultiSelected) && tabManager.tabs.count > 1 ? 1 : 0) - .allowsHitTesting((isHovering || isActive || isMultiSelected) && tabManager.tabs.count > 1) + .opacity((isHovering && tabManager.tabs.count > 1) ? 1 : 0) + .allowsHitTesting(isHovering && tabManager.tabs.count > 1) } if let subtitle = latestNotificationText { @@ -309,8 +574,33 @@ struct TabItemView: View { } .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) + closeTabs(targetIds, allowPinned: true) } .disabled(targetIds.isEmpty) @@ -358,9 +648,6 @@ struct TabItemView: View { if isMultiSelected { return Color.accentColor.opacity(0.25) } - if isHovering { - return Color(nsColor: .controlBackgroundColor).opacity(0.5) - } return Color.clear } @@ -398,32 +685,36 @@ struct TabItemView: View { return tabManager.tabs.compactMap { baseIds.contains($0.id) ? $0.id : nil } } - private func closeTabs(_ targetIds: [UUID]) { - for id in targetIds { + 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(targetIds) + 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) + 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) + 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) + closeTabs(idsToClose, allowPinned: false) } private func markTabsRead(_ targetIds: [UUID]) { @@ -495,9 +786,569 @@ struct TabItemView: View { } 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 + } + } +} + + +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))) + ) + } +} diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 2b0cdf17..7914ccf3 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -301,6 +301,61 @@ class GhosttyApp { } } + func reloadConfiguration(soft: Bool = false) { + guard let app else { return } + if soft, let config { + ghostty_app_update_config(app, config) + NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) + return + } + + guard let newConfig = ghostty_config_new() else { return } + ghostty_config_load_default_files(newConfig) + ghostty_config_finalize(newConfig) + ghostty_app_update_config(app, newConfig) + updateDefaultBackground(from: newConfig) + DispatchQueue.main.async { + self.applyBackgroundToKeyWindow() + } + if let oldConfig = config { + ghostty_config_free(oldConfig) + } + config = newConfig + NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil) + } + + func reloadConfiguration(for surface: ghostty_surface_t, soft: Bool = false) { + if soft, let config { + ghostty_surface_update_config(surface, config) + return + } + + guard let newConfig = ghostty_config_new() else { return } + ghostty_config_load_default_files(newConfig) + ghostty_config_finalize(newConfig) + ghostty_surface_update_config(surface, newConfig) + ghostty_config_free(newConfig) + } + + func openConfigurationInTextEdit() { + #if os(macOS) + let path = ghosttyStringValue(ghostty_config_open_path()) + guard !path.isEmpty else { return } + let fileURL = URL(fileURLWithPath: path) + let editorURL = URL(fileURLWithPath: "/System/Applications/TextEdit.app") + let configuration = NSWorkspace.OpenConfiguration() + NSWorkspace.shared.open([fileURL], withApplicationAt: editorURL, configuration: configuration) + #endif + } + + private func ghosttyStringValue(_ value: ghostty_string_s) -> String { + defer { ghostty_string_free(value) } + guard let ptr = value.ptr, value.len > 0 else { return "" } + let rawPtr = UnsafeRawPointer(ptr).assumingMemoryBound(to: UInt8.self) + let buffer = UnsafeBufferPointer(start: rawPtr, count: Int(value.len)) + return String(decoding: buffer, as: UTF8.self) + } + private func updateDefaultBackground(from config: ghostty_config_t?) { guard let config else { return } @@ -389,6 +444,14 @@ class GhosttyApp { return true } + if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG { + let soft = action.action.reload_config.soft + performOnMain { + GhosttyApp.shared.reloadConfiguration(soft: soft) + } + return true + } + if action.tag == GHOSTTY_ACTION_COLOR_CHANGE, action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND { let change = action.action.color_change @@ -534,13 +597,15 @@ class GhosttyApp { case GHOSTTY_ACTION_SET_TITLE: let title = action.action.set_title.title .flatMap { String(cString: $0) } ?? "" - if let tabId = surfaceView.tabId { + if let tabId = surfaceView.tabId, + let surfaceId = surfaceView.terminalSurface?.id { DispatchQueue.main.async { NotificationCenter.default.post( name: .ghosttyDidSetTitle, object: surfaceView, userInfo: [ GhosttyNotificationKey.tabId: tabId, + GhosttyNotificationKey.surfaceId: surfaceId, GhosttyNotificationKey.title: title, ] ) @@ -604,6 +669,16 @@ class GhosttyApp { surfaceView.applyWindowBackgroundIfActive() } return true + case GHOSTTY_ACTION_RELOAD_CONFIG: + let soft = action.action.reload_config.soft + return performOnMain { + if let surface = surfaceView.terminalSurface?.surface { + GhosttyApp.shared.reloadConfiguration(for: surface, soft: soft) + } else { + GhosttyApp.shared.reloadConfiguration(soft: soft) + } + return true + } case GHOSTTY_ACTION_KEY_SEQUENCE: return performOnMain { surfaceView.updateKeySequence(action.action.key_sequence) @@ -620,15 +695,33 @@ class GhosttyApp { } private func applyBackgroundToKeyWindow() { - guard let window = NSApp.keyWindow ?? NSApp.windows.first else { return } - let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity) - window.backgroundColor = color - window.isOpaque = color.alphaComponent >= 1.0 - if backgroundLogEnabled { - logBackground("applied default window background color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))") + guard let window = activeMainWindow() else { return } + // Check if sidebar uses behindWindow blur - if so, keep window non-opaque + let sidebarBlendMode = UserDefaults.standard.string(forKey: "sidebarBlendMode") ?? "withinWindow" + if sidebarBlendMode == "behindWindow" { + window.backgroundColor = .clear + window.isOpaque = false + if backgroundLogEnabled { + logBackground("applied transparent window for behindWindow blur") + } + } else { + let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity) + window.backgroundColor = color + window.isOpaque = color.alphaComponent >= 1.0 + if backgroundLogEnabled { + logBackground("applied default window background color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))") + } } } + private func activeMainWindow() -> NSWindow? { + let keyWindow = NSApp.keyWindow + if keyWindow?.identifier?.rawValue == "cmux.main" { + return keyWindow + } + return NSApp.windows.first(where: { $0.identifier?.rawValue == "cmux.main" }) + } + func logBackground(_ message: String) { let line = "cmux bg: \(message)\n" if let data = line.data(using: .utf8) { @@ -991,8 +1084,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { } applySurfaceBackground() let color = effectiveBackgroundColor() - window.backgroundColor = color - window.isOpaque = color.alphaComponent >= 1.0 + // Check if sidebar uses behindWindow blur - if so, keep window non-opaque + let sidebarBlendMode = UserDefaults.standard.string(forKey: "sidebarBlendMode") ?? "withinWindow" + if sidebarBlendMode == "behindWindow" { + window.backgroundColor = .clear + window.isOpaque = false + } else { + window.backgroundColor = color + window.isOpaque = color.alphaComponent >= 1.0 + } if GhosttyApp.shared.backgroundLogEnabled { GhosttyApp.shared.logBackground("applied window background tab=\(tabId?.uuidString ?? "unknown") color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))") } @@ -1758,6 +1858,7 @@ enum GhosttyNotificationKey { static let scrollbar = "ghostty.scrollbar" static let cellSize = "ghostty.cellSize" static let tabId = "ghostty.tabId" + static let surfaceId = "ghostty.surfaceId" static let title = "ghostty.title" } @@ -1765,6 +1866,7 @@ extension Notification.Name { static let ghosttyDidUpdateScrollbar = Notification.Name("ghosttyDidUpdateScrollbar") static let ghosttyDidUpdateCellSize = Notification.Name("ghosttyDidUpdateCellSize") static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus") + static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload") } // MARK: - Scroll View Wrapper (Ghostty-style scrollbar) diff --git a/Sources/Splits/TerminalSplitTreeView.swift b/Sources/Splits/TerminalSplitTreeView.swift index 59e5478d..b2e61579 100644 --- a/Sources/Splits/TerminalSplitTreeView.swift +++ b/Sources/Splits/TerminalSplitTreeView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Foundation struct TerminalSplitTreeView: View { @ObservedObject var tab: Tab @@ -37,6 +38,9 @@ struct TerminalSplitTreeView: View { .onAppear { tab.updateSplitViewSize(proxy.size) } .onChange(of: proxy.size) { tab.updateSplitViewSize($0) } }) + .onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in + config = GhosttyConfig.load() + } } } diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 3634d5ee..bcedffa0 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -5,20 +5,36 @@ import Foundation class Tab: Identifiable, ObservableObject { let id: UUID @Published var title: String + @Published var customTitle: String? + @Published var isPinned: Bool = false @Published var currentDirectory: String @Published var splitTree: SplitTree @Published var focusedSurfaceId: UUID? { didSet { guard let focusedSurfaceId else { return } AppDelegate.shared?.tabManager?.rememberFocusedSurface(tabId: id, surfaceId: focusedSurfaceId) + AppDelegate.shared?.tabManager?.focusedSurfaceTitleDidChange(tabId: id) + NotificationCenter.default.post( + name: .ghosttyDidFocusSurface, + object: nil, + userInfo: [ + GhosttyNotificationKey.tabId: id, + GhosttyNotificationKey.surfaceId: focusedSurfaceId + ] + ) } } @Published var surfaceDirectories: [UUID: String] = [:] + @Published var surfaceTitles: [UUID: String] = [:] var splitViewSize: CGSize = .zero + private var processTitle: String + init(title: String = "Terminal", workingDirectory: String? = nil) { self.id = UUID() + self.processTitle = title self.title = title + self.customTitle = nil let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty self.currentDirectory = hasWorkingDirectory @@ -39,6 +55,28 @@ class Tab: Identifiable, ObservableObject { return surface(for: focusedSurfaceId) } + var hasCustomTitle: Bool { + let trimmed = customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !trimmed.isEmpty + } + + func applyProcessTitle(_ title: String) { + processTitle = title + guard customTitle == nil else { return } + self.title = title + } + + func setCustomTitle(_ title: String?) { + let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + customTitle = nil + self.title = processTitle + } else { + customTitle = trimmed + self.title = trimmed + } + } + func surface(for id: UUID) -> TerminalSurface? { guard let node = splitTree.root?.find(id: id) else { return nil } if case .leaf(let view) = node { @@ -305,8 +343,9 @@ class TabManager: ObservableObject { ) { [weak self] notification in guard let self else { return } guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return } + guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return } guard let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else { return } - self.updateTabTitle(tabId: tabId, title: title) + self.updateSurfaceTitle(tabId: tabId, surfaceId: surfaceId, title: title) }) } @@ -383,6 +422,11 @@ class TabManager: ObservableObject { let index = tabs.firstIndex(where: { $0.id == selectedTabId }) else { return tabs.count } + let selectedTab = tabs[index] + if selectedTab.isPinned { + let lastPinnedIndex = tabs.lastIndex(where: { $0.isPinned }) ?? -1 + return min(lastPinnedIndex + 1, tabs.count) + } return min(index + 1, tabs.count) } @@ -403,7 +447,9 @@ class TabManager: ObservableObject { guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } guard index != 0 else { return } let tab = tabs.remove(at: index) - tabs.insert(tab, at: 0) + let pinnedCount = tabs.filter { $0.isPinned }.count + let insertIndex = tab.isPinned ? 0 : pinnedCount + tabs.insert(tab, at: insertIndex) } func moveTabsToTop(_ tabIds: Set) { @@ -411,7 +457,43 @@ class TabManager: ObservableObject { let selectedTabs = tabs.filter { tabIds.contains($0.id) } guard !selectedTabs.isEmpty else { return } let remainingTabs = tabs.filter { !tabIds.contains($0.id) } - tabs = selectedTabs + remainingTabs + let selectedPinned = selectedTabs.filter { $0.isPinned } + let selectedUnpinned = selectedTabs.filter { !$0.isPinned } + let remainingPinned = remainingTabs.filter { $0.isPinned } + let remainingUnpinned = remainingTabs.filter { !$0.isPinned } + tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned + } + + func setCustomTitle(tabId: UUID, title: String?) { + guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } + tabs[index].setCustomTitle(title) + if selectedTabId == tabId { + updateWindowTitle(for: tabs[index]) + } + } + + func clearCustomTitle(tabId: UUID) { + setCustomTitle(tabId: tabId, title: nil) + } + + func togglePin(tabId: UUID) { + guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } + let tab = tabs[index] + setPinned(tab, pinned: !tab.isPinned) + } + + func setPinned(_ tab: Tab, pinned: Bool) { + guard tab.isPinned != pinned else { return } + tab.isPinned = pinned + reorderTabForPinnedState(tab) + } + + private func reorderTabForPinnedState(_ tab: Tab) { + guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return } + tabs.remove(at: index) + let pinnedCount = tabs.filter { $0.isPinned }.count + let insertIndex = min(pinnedCount, tabs.count) + tabs.insert(tab, at: insertIndex) } func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) { @@ -570,17 +652,33 @@ class TabManager: ObservableObject { notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId) } - private func updateTabTitle(tabId: UUID, title: String) { + private func updateSurfaceTitle(tabId: UUID, surfaceId: UUID, title: String) { guard !title.isEmpty else { return } guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return } - if tabs[index].title != title { - tabs[index].title = title + let tab = tabs[index] + + // Store title per-surface + tab.surfaceTitles[surfaceId] = title + + // Only update tab's display title if this surface is focused + if tab.focusedSurfaceId == surfaceId { + tab.applyProcessTitle(title) if selectedTabId == tabId { - updateWindowTitle(for: tabs[index]) + updateWindowTitle(for: tab) } } } + func focusedSurfaceTitleDidChange(tabId: UUID) { + guard let tab = tabs.first(where: { $0.id == tabId }), + let focusedSurfaceId = tab.focusedSurfaceId, + let title = tab.surfaceTitles[focusedSurfaceId] else { return } + tab.applyProcessTitle(title) + if selectedTabId == tabId { + updateWindowTitle(for: tab) + } + } + private func updateWindowTitleForSelectedTab() { guard let selectedTabId, let tab = tabs.first(where: { $0.id == selectedTabId }) else { @@ -818,4 +916,5 @@ class TabManager: ObservableObject { extension Notification.Name { static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle") static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab") + static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface") } diff --git a/Sources/TerminalView.swift b/Sources/TerminalView.swift index 58d896a3..932e5dd3 100644 --- a/Sources/TerminalView.swift +++ b/Sources/TerminalView.swift @@ -267,7 +267,7 @@ struct SwiftTermView: NSViewRepresentable { func setTerminalTitle(source: LocalProcessTerminalView, title: String) { DispatchQueue.main.async { if !title.isEmpty { - self.tab.title = title + self.tab.applyProcessTitle(title) } } } diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index e051cf06..10a4a58d 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -204,15 +204,23 @@ private struct NotificationsAnchorView: NSViewRepresentable { let onResolve: (NSView) -> Void func makeNSView(context: Context) -> NSView { - let view = NSView(frame: .zero) - DispatchQueue.main.async { + let view = AnchorNSView() + view.onLayout = { [weak view] in + guard let view else { return } onResolve(view) } return view } - func updateNSView(_ nsView: NSView, context: Context) { - // Only need to resolve once in makeNSView - the view reference doesn't change + func updateNSView(_ nsView: NSView, context: Context) {} +} + +private final class AnchorNSView: NSView { + var onLayout: (() -> Void)? + + override func layout() { + super.layout() + onLayout?() } } @@ -225,8 +233,12 @@ private struct TitlebarControlButton: View { var body: some View { Button(action: action) { content() + .frame(width: config.buttonSize, height: config.buttonSize) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .frame(width: config.buttonSize, height: config.buttonSize) + .contentShape(Rectangle()) .background(hoverBackground) .onHover { isHovering = $0 } } @@ -281,7 +293,7 @@ private struct TitlebarControlsView: View { } .frame(width: config.buttonSize, height: config.buttonSize) } - .background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }) + .overlay(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }.allowsHitTesting(false)) .accessibilityLabel("Notifications") .help("Show notifications (Cmd+Shift+I)") @@ -420,7 +432,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont return } // Recreate content view each time to avoid stale observers when popover is hidden - notificationsPopover.contentViewController = NSHostingController( + let hostingController = NSHostingController( rootView: NotificationsPopoverView( notificationStore: notificationStore, onDismiss: { [weak notificationsPopover] in @@ -428,9 +440,33 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont } ) ) - let anchorView = viewModel.notificationsAnchorView ?? hostingView + hostingController.view.wantsLayer = true + hostingController.view.layer?.backgroundColor = .clear + notificationsPopover.contentViewController = hostingController + + guard let window = view.window ?? hostingView.window ?? NSApp.keyWindow, + let contentView = window.contentView else { + return + } + + // Force layout to ensure geometry is current. + contentView.layoutSubtreeIfNeeded() + + if let anchorView = viewModel.notificationsAnchorView, anchorView.window != nil { + anchorView.superview?.layoutSubtreeIfNeeded() + let anchorRect = anchorView.convert(anchorView.bounds, to: contentView) + if !anchorRect.isEmpty { + notificationsPopover.animates = animated + notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY) + return + } + } + + // Fallback: position near top-left of the window content. + let bounds = contentView.bounds + let anchorRect = NSRect(x: 12, y: bounds.maxY - 8, width: 1, height: 1) notificationsPopover.animates = animated - notificationsPopover.show(relativeTo: anchorView.bounds, of: anchorView, preferredEdge: .maxY) + notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY) } private func makeNotificationsPopover() -> NSPopover { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 7f0ba6ea..ea4f4ce6 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -106,7 +106,7 @@ struct cmuxApp: App { updateSocketController() } } - .windowToolbarStyle(.automatic) + .windowStyle(.hiddenTitleBar) Settings { SettingsRootView() } @@ -117,6 +117,13 @@ struct cmuxApp: App { Button("About cmuxterm") { showAboutPanel() } + Button("Ghostty Settings…") { + GhosttyApp.shared.openConfigurationInTextEdit() + } + Button("Reload Configuration") { + GhosttyApp.shared.reloadConfiguration() + } + .keyboardShortcut("r", modifiers: [.command, .shift]) Divider() Button("Check for Updates…") { appDelegate.checkForUpdates(nil) @@ -161,6 +168,16 @@ struct cmuxApp: App { Divider() + Button("Sidebar Debug…") { + SidebarDebugWindowController.shared.show() + } + + Button("Background Debug…") { + BackgroundDebugWindowController.shared.show() + } + + Divider() + Picker("Titlebar Controls Style", selection: $titlebarControlsStyle) { ForEach(TitlebarControlsStyle.allCases) { style in Text(style.menuTitle).tag(style.rawValue) @@ -394,6 +411,40 @@ private final class AboutWindowController: NSWindowController, NSWindowDelegate } } +private final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = SidebarDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 520), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Sidebar Debug" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("cmux.sidebarDebug") + window.center() + window.contentView = NSHostingView(rootView: SidebarDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + private struct AboutPanelView: View { @Environment(\.openURL) private var openURL @@ -480,6 +531,297 @@ private struct AboutPanelView: View { } } +private struct SidebarDebugView: View { + @AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue + @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 { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Sidebar Appearance") + .font(.headline) + + GroupBox("Presets") { + Picker("Preset", selection: $sidebarPreset) { + ForEach(SidebarPresetOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .onChange(of: sidebarPreset) { _ in + applyPreset() + } + .padding(.top, 2) + } + + GroupBox("Blur") { + VStack(alignment: .leading, spacing: 8) { + Picker("Material", selection: $sidebarMaterial) { + ForEach(SidebarMaterialOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + + Picker("Blending", selection: $sidebarBlendMode) { + ForEach(SidebarBlendModeOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + + Picker("State", selection: $sidebarState) { + ForEach(SidebarStateOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + + HStack(spacing: 8) { + Text("Strength") + Slider(value: $sidebarBlurOpacity, in: 0...1) + Text(String(format: "%.0f%%", sidebarBlurOpacity * 100)) + .font(.caption) + .frame(width: 44, alignment: .trailing) + } + } + .padding(.top, 2) + } + + GroupBox("Tint") { + VStack(alignment: .leading, spacing: 8) { + ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false) + + HStack(spacing: 8) { + Text("Opacity") + Slider(value: $sidebarTintOpacity, in: 0...0.7) + Text(String(format: "%.0f%%", sidebarTintOpacity * 100)) + .font(.caption) + .frame(width: 44, alignment: .trailing) + } + } + .padding(.top, 2) + } + + GroupBox("Shape") { + HStack(spacing: 8) { + Text("Corner Radius") + Slider(value: $sidebarCornerRadius, in: 0...20) + Text(String(format: "%.0f", sidebarCornerRadius)) + .font(.caption) + .frame(width: 32, alignment: .trailing) + } + .padding(.top, 2) + } + + HStack(spacing: 12) { + Button("Reset Tint") { + sidebarTintOpacity = 0.62 + sidebarTintHex = "#000000" + } + Button("Reset Blur") { + sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue + sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue + sidebarState = SidebarStateOption.active.rawValue + sidebarBlurOpacity = 0.98 + } + Button("Reset Shape") { + sidebarCornerRadius = 0.0 + } + } + + Button("Copy Config") { + copySidebarConfig() + } + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + } + + private var tintColorBinding: Binding { + Binding( + get: { + Color(nsColor: NSColor(hex: sidebarTintHex) ?? .black) + }, + set: { newColor in + let nsColor = NSColor(newColor) + sidebarTintHex = nsColor.hexString() + } + ) + } + + private func copySidebarConfig() { + let payload = """ + sidebarPreset=\(sidebarPreset) + sidebarMaterial=\(sidebarMaterial) + sidebarBlendMode=\(sidebarBlendMode) + sidebarState=\(sidebarState) + sidebarBlurOpacity=\(String(format: "%.2f", sidebarBlurOpacity)) + sidebarTintHex=\(sidebarTintHex) + sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity)) + sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius)) + """ + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } + + private func applyPreset() { + guard let preset = SidebarPresetOption(rawValue: sidebarPreset) else { return } + sidebarMaterial = preset.material.rawValue + sidebarBlendMode = preset.blendMode.rawValue + sidebarState = preset.state.rawValue + sidebarTintHex = preset.tintHex + sidebarTintOpacity = preset.tintOpacity + sidebarCornerRadius = preset.cornerRadius + sidebarBlurOpacity = preset.blurOpacity + } +} + +// MARK: - Background Debug Window + +private final class BackgroundDebugWindowController: NSWindowController, NSWindowDelegate { + static let shared = BackgroundDebugWindowController() + + private init() { + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 360, height: 300), + styleMask: [.titled, .closable, .utilityWindow], + backing: .buffered, + defer: false + ) + window.title = "Background Debug" + window.titleVisibility = .visible + window.titlebarAppearsTransparent = false + window.isMovableByWindowBackground = true + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("cmux.backgroundDebug") + window.center() + window.contentView = NSHostingView(rootView: BackgroundDebugView()) + AppDelegate.shared?.applyWindowDecorations(to: window) + super.init(window: window) + window.delegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show() { + window?.center() + window?.makeKeyAndOrderFront(nil) + } +} + +private struct BackgroundDebugView: View { + @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" + @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.05 + @AppStorage("bgGlassMaterial") private var bgGlassMaterial = "hudWindow" + @AppStorage("bgGlassEnabled") private var bgGlassEnabled = true + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text("Window Background Glass") + .font(.headline) + + GroupBox("Glass Effect") { + VStack(alignment: .leading, spacing: 8) { + Toggle("Enable Glass Effect", isOn: $bgGlassEnabled) + + Picker("Material", selection: $bgGlassMaterial) { + Text("HUD Window").tag("hudWindow") + Text("Under Window").tag("underWindowBackground") + Text("Sidebar").tag("sidebar") + Text("Menu").tag("menu") + Text("Popover").tag("popover") + } + .disabled(!bgGlassEnabled) + } + .padding(.top, 2) + } + + GroupBox("Tint") { + VStack(alignment: .leading, spacing: 8) { + ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false) + .disabled(!bgGlassEnabled) + + HStack(spacing: 8) { + Text("Opacity") + Slider(value: $bgGlassTintOpacity, in: 0...0.8) + .disabled(!bgGlassEnabled) + Text(String(format: "%.0f%%", bgGlassTintOpacity * 100)) + .font(.caption) + .frame(width: 44, alignment: .trailing) + } + } + .padding(.top, 2) + } + + HStack(spacing: 12) { + Button("Reset") { + bgGlassTintHex = "#000000" + bgGlassTintOpacity = 0.05 + bgGlassMaterial = "hudWindow" + bgGlassEnabled = true + updateWindowGlassTint() + } + + Button("Copy Config") { + copyBgConfig() + } + } + + Text("Tint changes apply live. Enable/disable requires reload.") + .font(.caption) + .foregroundColor(.secondary) + + Spacer(minLength: 0) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() } + .onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() } + } + + private func updateWindowGlassTint() { + 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) + } + + private var tintColorBinding: Binding { + Binding( + get: { + Color(nsColor: NSColor(hex: bgGlassTintHex) ?? .black) + }, + set: { newColor in + let nsColor = NSColor(newColor) + bgGlassTintHex = nsColor.hexString() + } + ) + } + + private func copyBgConfig() { + let payload = """ + bgGlassEnabled=\(bgGlassEnabled) + bgGlassMaterial=\(bgGlassMaterial) + bgGlassTintHex=\(bgGlassTintHex) + bgGlassTintOpacity=\(String(format: "%.2f", bgGlassTintOpacity)) + """ + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } +} + private struct AboutPropertyRow: View { private let label: String private let text: String diff --git a/scripts/reload.sh b/scripts/reload.sh index 7bdde451..396216d7 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -203,7 +203,13 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then APP_PATH="$TAG_APP_PATH" fi -pkill -f "${APP_PATH}/Contents/MacOS/" || true +# Ensure any running instance is fully terminated, regardless of DerivedData path. +/usr/bin/osascript -e "tell application id \"${BUNDLE_ID}\" to quit" >/dev/null 2>&1 || true +sleep 0.3 +pkill -f "/${BASE_APP_NAME}.app/Contents/MacOS/${BASE_APP_NAME}" || true +if [[ "${APP_NAME}" != "${BASE_APP_NAME}" ]]; then + pkill -f "/${APP_NAME}.app/Contents/MacOS/${APP_NAME}" || true +fi sleep 0.3 CMUXD_SRC="$PWD/cmuxd/zig-out/bin/cmuxd" if [[ -d "$PWD/cmuxd" ]]; then