Add sidebar blur effect with withinWindow blending (#9)
* Add sidebar blur effect with withinWindow blending - Add NSVisualEffectView-based blur backdrop for sidebar - Support withinWindow blending mode to blur terminal content behind sidebar - Auto-switch to overlay layout when withinWindow mode is selected - Add sidebar debug panel with material, blending, tint, and opacity controls - Add preset options (HUD Glass, Popover Glass, etc.) - Default to HUD Glass preset with withinWindow blur * Simplify tab close button visibility and remove hover background Show close button only on hover instead of when active/multi-selected. Remove the hover background color from tabs for cleaner appearance. * Add config reload support with notification system - Add reloadConfiguration() methods for app-wide and per-surface reload - Handle GHOSTTY_ACTION_RELOAD_CONFIG action from Ghostty - Add ghosttyConfigDidReload notification for views to react - TerminalSplitTreeView reloads GhosttyConfig on notification - Add openConfigurationInTextEdit() helper - Fix activeMainWindow() to correctly find main window * Add custom tab titles and pinned tabs support - Add customTitle and isPinned properties to Tab - Separate process title from custom title with applyProcessTitle() - Add setCustomTitle()/clearCustomTitle() for user-defined tab names - Add togglePin()/setPinned() with automatic reordering - Pinned tabs stay at the top, new tabs insert after pinned section - moveTabToTop/moveTabsToTop respect pinned tab ordering * Add --panel option to new-split command - CLI: Parse --panel <id|index> option for new-split - Controller: Resolve panel argument to split specific surface - Return new panel UUID on successful split creation * Fix notifications popover positioning with layout-aware anchor - Use AnchorNSView with layout callback for reliable positioning - Force layout before showing popover to ensure current geometry - Convert anchor bounds to window content view coordinates - Add fallback positioning near top-left when anchor unavailable - Fix button hit testing with explicit frame and contentShape * Improve app termination in reload script Use osascript to gracefully quit by bundle ID before pkill fallback. Add more robust pkill patterns to catch instances from any DerivedData path. * Add sidebar blur effect with live-adjustable glass settings - Add WindowGlassEffect for window-level NSGlassEffectView (macOS 26+) - Add SidebarBackdrop with configurable material, blend mode, tint, and opacity - Add Sidebar Debug panel (Debug menu) for live adjustment of sidebar appearance - Add Background Debug panel for window glass tint settings - Support both behindWindow and withinWindow blur modes - Live tint updates without requiring window reload * Align titlebar text to left edge of content area
This commit is contained in:
parent
600683cd7d
commit
cc71e5797e
8 changed files with 1548 additions and 108 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TerminalSurface>
|
||||
@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<UUID>) {
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Content: View>: 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 {
|
||||
|
|
|
|||
|
|
@ -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<Color> {
|
||||
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<Color> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue