Move GeometryReader from wrapping the entire VStack to wrapping only the ScrollView so proxy.size.height reflects available height (minus pill), preventing unnecessary scrollability that triggered macOS horizontal insets. Also clamp update pill text width with maxWidth instead of fixed width so it truncates gracefully at narrow sidebar widths and grows when wider, add horizontal padding, left-align truncated text, and add debug menu item for testing with long nightly version strings.
2725 lines
109 KiB
Swift
2725 lines
109 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
import Darwin
|
|
|
|
@main
|
|
struct cmuxApp: App {
|
|
@StateObject private var tabManager = TabManager()
|
|
@StateObject private var notificationStore = TerminalNotificationStore.shared
|
|
@StateObject private var sidebarState = SidebarState()
|
|
@StateObject private var sidebarSelectionState = SidebarSelectionState()
|
|
private let primaryWindowId = UUID()
|
|
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
|
|
@AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue
|
|
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
|
|
@AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data()
|
|
@AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data()
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
|
|
init() {
|
|
configureGhosttyEnvironment()
|
|
// Start the terminal controller for programmatic control
|
|
// This runs after TabManager is created via @StateObject
|
|
let defaults = UserDefaults.standard
|
|
if defaults.object(forKey: SocketControlSettings.appStorageKey) == nil,
|
|
let legacy = defaults.object(forKey: SocketControlSettings.legacyEnabledKey) as? Bool {
|
|
defaults.set(legacy ? SocketControlMode.full.rawValue : SocketControlMode.off.rawValue,
|
|
forKey: SocketControlSettings.appStorageKey)
|
|
}
|
|
migrateSidebarAppearanceDefaultsIfNeeded(defaults: defaults)
|
|
|
|
// UI tests depend on AppDelegate wiring happening even if SwiftUI view appearance
|
|
// callbacks (e.g. `.onAppear`) are delayed or skipped.
|
|
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
|
|
}
|
|
|
|
private func configureGhosttyEnvironment() {
|
|
let fileManager = FileManager.default
|
|
let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty"
|
|
let bundledGhosttyURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty")
|
|
var resolvedResourcesDir: String?
|
|
|
|
if getenv("GHOSTTY_RESOURCES_DIR") == nil {
|
|
if let bundledGhosttyURL,
|
|
fileManager.fileExists(atPath: bundledGhosttyURL.path),
|
|
fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) {
|
|
resolvedResourcesDir = bundledGhosttyURL.path
|
|
} else if fileManager.fileExists(atPath: ghosttyAppResources) {
|
|
resolvedResourcesDir = ghosttyAppResources
|
|
} else if let bundledGhosttyURL, fileManager.fileExists(atPath: bundledGhosttyURL.path) {
|
|
resolvedResourcesDir = bundledGhosttyURL.path
|
|
}
|
|
|
|
if let resolvedResourcesDir {
|
|
setenv("GHOSTTY_RESOURCES_DIR", resolvedResourcesDir, 1)
|
|
}
|
|
}
|
|
|
|
if getenv("TERM") == nil {
|
|
setenv("TERM", "xterm-ghostty", 1)
|
|
}
|
|
|
|
if getenv("TERM_PROGRAM") == nil {
|
|
setenv("TERM_PROGRAM", "ghostty", 1)
|
|
}
|
|
|
|
if let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap({ String(cString: $0) }) {
|
|
let resourcesURL = URL(fileURLWithPath: resourcesDir)
|
|
let resourcesParent = resourcesURL.deletingLastPathComponent()
|
|
let dataDir = resourcesParent.path
|
|
let manDir = resourcesParent.appendingPathComponent("man").path
|
|
|
|
appendEnvPathIfMissing(
|
|
"XDG_DATA_DIRS",
|
|
path: dataDir,
|
|
defaultValue: "/usr/local/share:/usr/share"
|
|
)
|
|
appendEnvPathIfMissing("MANPATH", path: manDir)
|
|
}
|
|
}
|
|
|
|
private func appendEnvPathIfMissing(_ key: String, path: String, defaultValue: String? = nil) {
|
|
if path.isEmpty { return }
|
|
var current = getenv(key).flatMap { String(cString: $0) } ?? ""
|
|
if current.isEmpty, let defaultValue {
|
|
current = defaultValue
|
|
}
|
|
if current.split(separator: ":").contains(Substring(path)) {
|
|
return
|
|
}
|
|
let updated = current.isEmpty ? path : "\(current):\(path)"
|
|
setenv(key, updated, 1)
|
|
}
|
|
|
|
private func migrateSidebarAppearanceDefaultsIfNeeded(defaults: UserDefaults) {
|
|
let migrationKey = "sidebarAppearanceDefaultsVersion"
|
|
let targetVersion = 1
|
|
guard defaults.integer(forKey: migrationKey) < targetVersion else { return }
|
|
|
|
func normalizeHex(_ value: String) -> String {
|
|
value
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.replacingOccurrences(of: "#", with: "")
|
|
.uppercased()
|
|
}
|
|
|
|
func approximatelyEqual(_ lhs: Double, _ rhs: Double, tolerance: Double = 0.0001) -> Bool {
|
|
abs(lhs - rhs) <= tolerance
|
|
}
|
|
|
|
let material = defaults.string(forKey: "sidebarMaterial") ?? SidebarMaterialOption.sidebar.rawValue
|
|
let blendMode = defaults.string(forKey: "sidebarBlendMode") ?? SidebarBlendModeOption.behindWindow.rawValue
|
|
let state = defaults.string(forKey: "sidebarState") ?? SidebarStateOption.followWindow.rawValue
|
|
let tintHex = defaults.string(forKey: "sidebarTintHex") ?? "#101010"
|
|
let tintOpacity = defaults.object(forKey: "sidebarTintOpacity") as? Double ?? 0.54
|
|
let blurOpacity = defaults.object(forKey: "sidebarBlurOpacity") as? Double ?? 0.79
|
|
let cornerRadius = defaults.object(forKey: "sidebarCornerRadius") as? Double ?? 0.0
|
|
|
|
let usesLegacyDefaults =
|
|
material == SidebarMaterialOption.sidebar.rawValue &&
|
|
blendMode == SidebarBlendModeOption.behindWindow.rawValue &&
|
|
state == SidebarStateOption.followWindow.rawValue &&
|
|
normalizeHex(tintHex) == "101010" &&
|
|
approximatelyEqual(tintOpacity, 0.54) &&
|
|
approximatelyEqual(blurOpacity, 0.79) &&
|
|
approximatelyEqual(cornerRadius, 0.0)
|
|
|
|
if usesLegacyDefaults {
|
|
let preset = SidebarPresetOption.nativeSidebar
|
|
defaults.set(preset.rawValue, forKey: "sidebarPreset")
|
|
defaults.set(preset.material.rawValue, forKey: "sidebarMaterial")
|
|
defaults.set(preset.blendMode.rawValue, forKey: "sidebarBlendMode")
|
|
defaults.set(preset.state.rawValue, forKey: "sidebarState")
|
|
defaults.set(preset.tintHex, forKey: "sidebarTintHex")
|
|
defaults.set(preset.tintOpacity, forKey: "sidebarTintOpacity")
|
|
defaults.set(preset.blurOpacity, forKey: "sidebarBlurOpacity")
|
|
defaults.set(preset.cornerRadius, forKey: "sidebarCornerRadius")
|
|
}
|
|
|
|
defaults.set(targetVersion, forKey: migrationKey)
|
|
}
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
ContentView(updateViewModel: appDelegate.updateViewModel, windowId: primaryWindowId)
|
|
.environmentObject(tabManager)
|
|
.environmentObject(notificationStore)
|
|
.environmentObject(sidebarState)
|
|
.environmentObject(sidebarSelectionState)
|
|
.onAppear {
|
|
#if DEBUG
|
|
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_MODE"] == "1" {
|
|
UpdateLogStore.shared.append("ui test: cmuxApp onAppear")
|
|
}
|
|
#endif
|
|
// Start the Unix socket controller for programmatic access
|
|
updateSocketController()
|
|
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
|
|
applyAppearance()
|
|
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" {
|
|
DispatchQueue.main.async {
|
|
showSettingsPanel()
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: appearanceMode) { _ in
|
|
applyAppearance()
|
|
}
|
|
.onChange(of: socketControlMode) { _ in
|
|
updateSocketController()
|
|
}
|
|
}
|
|
.windowStyle(.hiddenTitleBar)
|
|
.commands {
|
|
CommandGroup(replacing: .appSettings) {
|
|
Button("Settings…") {
|
|
showSettingsPanel()
|
|
}
|
|
.keyboardShortcut(",", modifiers: .command)
|
|
}
|
|
|
|
CommandGroup(replacing: .appInfo) {
|
|
Button("About cmux") {
|
|
showAboutPanel()
|
|
}
|
|
Button("Ghostty Settings…") {
|
|
GhosttyApp.shared.openConfigurationInTextEdit()
|
|
}
|
|
Button("Reload Configuration") {
|
|
GhosttyApp.shared.reloadConfiguration()
|
|
}
|
|
.keyboardShortcut(",", modifiers: [.command, .shift])
|
|
Divider()
|
|
Button("Check for Updates…") {
|
|
appDelegate.checkForUpdates(nil)
|
|
}
|
|
InstallUpdateMenuItem(model: appDelegate.updateViewModel)
|
|
}
|
|
|
|
#if DEBUG
|
|
CommandMenu("Update Pill") {
|
|
Button("Show Update Pill") {
|
|
appDelegate.showUpdatePill(nil)
|
|
}
|
|
Button("Show Long Nightly Pill") {
|
|
appDelegate.showUpdatePillLongNightly(nil)
|
|
}
|
|
Button("Show Loading State") {
|
|
appDelegate.showUpdatePillLoading(nil)
|
|
}
|
|
Button("Hide Update Pill") {
|
|
appDelegate.hideUpdatePill(nil)
|
|
}
|
|
Button("Automatic Update Pill") {
|
|
appDelegate.clearUpdatePillOverride(nil)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
CommandMenu("Update Logs") {
|
|
Button("Copy Update Logs") {
|
|
appDelegate.copyUpdateLogs(nil)
|
|
}
|
|
Button("Copy Focus Logs") {
|
|
appDelegate.copyFocusLogs(nil)
|
|
}
|
|
}
|
|
|
|
CommandMenu("Notifications") {
|
|
let snapshot = notificationMenuSnapshot
|
|
|
|
Button(snapshot.stateHintTitle) {}
|
|
.disabled(true)
|
|
|
|
if !snapshot.recentNotifications.isEmpty {
|
|
Divider()
|
|
|
|
ForEach(snapshot.recentNotifications) { notification in
|
|
Button(notificationMenuItemTitle(for: notification)) {
|
|
openNotificationFromMainMenu(notification)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
}
|
|
|
|
Button("Show Notifications") {
|
|
showNotificationsPopover()
|
|
}
|
|
|
|
Button("Jump to Latest Unread") {
|
|
appDelegate.jumpToLatestUnread()
|
|
}
|
|
.disabled(!snapshot.hasUnreadNotifications)
|
|
|
|
Button("Mark All Read") {
|
|
notificationStore.markAllRead()
|
|
}
|
|
.disabled(!snapshot.hasUnreadNotifications)
|
|
|
|
Button("Clear All") {
|
|
notificationStore.clearAll()
|
|
}
|
|
.disabled(!snapshot.hasNotifications)
|
|
}
|
|
|
|
#if DEBUG
|
|
CommandMenu("Debug") {
|
|
Button("New Tab With Lorem Search Text") {
|
|
appDelegate.openDebugLoremTab(nil)
|
|
}
|
|
|
|
Button("New Tab With Large Scrollback") {
|
|
appDelegate.openDebugScrollbackTab(nil)
|
|
}
|
|
|
|
Divider()
|
|
Menu("Debug Windows") {
|
|
Button("Debug Window Controls…") {
|
|
DebugWindowControlsWindowController.shared.show()
|
|
}
|
|
|
|
Button("Settings/About Titlebar Debug…") {
|
|
SettingsAboutTitlebarDebugWindowController.shared.show()
|
|
}
|
|
|
|
Divider()
|
|
Button("Sidebar Debug…") {
|
|
SidebarDebugWindowController.shared.show()
|
|
}
|
|
|
|
Button("Background Debug…") {
|
|
BackgroundDebugWindowController.shared.show()
|
|
}
|
|
|
|
Button("Menu Bar Extra Debug…") {
|
|
MenuBarExtraDebugWindowController.shared.show()
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Open All Debug Windows") {
|
|
openAllDebugWindows()
|
|
}
|
|
}
|
|
|
|
Toggle("Always Show Shortcut Hints", isOn: $alwaysShowShortcutHints)
|
|
|
|
Divider()
|
|
|
|
Picker("Titlebar Controls Style", selection: $titlebarControlsStyle) {
|
|
ForEach(TitlebarControlsStyle.allCases) { style in
|
|
Text(style.menuTitle).tag(style.rawValue)
|
|
}
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Trigger Sentry Test Crash") {
|
|
appDelegate.triggerSentryTestCrash(nil)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// New tab commands
|
|
CommandGroup(replacing: .newItem) {
|
|
Button("New Window") {
|
|
appDelegate.openNewMainWindow(nil)
|
|
}
|
|
.keyboardShortcut("n", modifiers: [.command, .shift])
|
|
|
|
Button("New Workspace") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).addTab()
|
|
}
|
|
}
|
|
|
|
// Close tab/workspace
|
|
CommandGroup(after: .newItem) {
|
|
// Terminal semantics:
|
|
// Cmd+W closes the focused tab (with confirmation if needed). If this is the last
|
|
// tab in the last workspace, it closes the window.
|
|
Button("Close Tab") {
|
|
closePanelOrWindow()
|
|
}
|
|
.keyboardShortcut("w", modifiers: .command)
|
|
|
|
// Cmd+Shift+W closes the current workspace (with confirmation if needed). If this
|
|
// is the last workspace, it closes the window.
|
|
Button("Close Workspace") {
|
|
closeTabOrWindow()
|
|
}
|
|
.keyboardShortcut("w", modifiers: [.command, .shift])
|
|
}
|
|
|
|
// Find
|
|
CommandGroup(after: .textEditing) {
|
|
Menu("Find") {
|
|
Button("Find…") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).startSearch()
|
|
}
|
|
.keyboardShortcut("f", modifiers: .command)
|
|
|
|
Button("Find Next") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).findNext()
|
|
}
|
|
.keyboardShortcut("g", modifiers: .command)
|
|
|
|
Button("Find Previous") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).findPrevious()
|
|
}
|
|
.keyboardShortcut("g", modifiers: [.command, .shift])
|
|
|
|
Divider()
|
|
|
|
Button("Hide Find Bar") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).hideFind()
|
|
}
|
|
.keyboardShortcut("f", modifiers: [.command, .shift])
|
|
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).isFindVisible))
|
|
|
|
Divider()
|
|
|
|
Button("Use Selection for Find") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).searchSelection()
|
|
}
|
|
.keyboardShortcut("e", modifiers: .command)
|
|
.disabled(!((AppDelegate.shared?.tabManager ?? tabManager).canUseSelectionForFind))
|
|
}
|
|
}
|
|
|
|
// Tab navigation
|
|
CommandGroup(after: .toolbar) {
|
|
Button("Toggle Sidebar") {
|
|
sidebarState.toggle()
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Next Surface") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).selectNextSurface()
|
|
}
|
|
|
|
Button("Previous Surface") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousSurface()
|
|
}
|
|
|
|
Button("Back") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goBack()
|
|
}
|
|
.keyboardShortcut("[", modifiers: .command)
|
|
|
|
Button("Forward") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.goForward()
|
|
}
|
|
.keyboardShortcut("]", modifiers: .command)
|
|
|
|
Button("Reload Page") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).focusedBrowserPanel?.reload()
|
|
}
|
|
.keyboardShortcut("r", modifiers: .command)
|
|
|
|
Button("Zoom In") {
|
|
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser()
|
|
}
|
|
.keyboardShortcut("=", modifiers: .command)
|
|
|
|
Button("Zoom Out") {
|
|
_ = (AppDelegate.shared?.tabManager ?? tabManager).zoomOutFocusedBrowser()
|
|
}
|
|
.keyboardShortcut("-", modifiers: .command)
|
|
|
|
Button("Actual Size") {
|
|
_ = (AppDelegate.shared?.tabManager ?? tabManager).resetZoomFocusedBrowser()
|
|
}
|
|
.keyboardShortcut("0", modifiers: .command)
|
|
|
|
Button("Clear Browser History") {
|
|
BrowserHistoryStore.shared.clearHistory()
|
|
}
|
|
|
|
Button("Next Workspace") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).selectNextTab()
|
|
}
|
|
|
|
Button("Previous Workspace") {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).selectPreviousTab()
|
|
}
|
|
|
|
Divider()
|
|
|
|
splitCommandButton(title: "Split Right", shortcut: splitRightMenuShortcut) {
|
|
performSplitFromMenu(direction: .right)
|
|
}
|
|
|
|
splitCommandButton(title: "Split Down", shortcut: splitDownMenuShortcut) {
|
|
performSplitFromMenu(direction: .down)
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
|
|
ForEach(1...9, id: \.self) { number in
|
|
Button("Tab \(number)") {
|
|
let manager = (AppDelegate.shared?.tabManager ?? tabManager)
|
|
if let targetIndex = WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: number, workspaceCount: manager.tabs.count) {
|
|
manager.selectTab(at: targetIndex)
|
|
}
|
|
}
|
|
.keyboardShortcut(KeyEquivalent(Character("\(number)")), modifiers: .command)
|
|
}
|
|
|
|
Divider()
|
|
|
|
Button("Jump to Latest Unread") {
|
|
AppDelegate.shared?.jumpToLatestUnread()
|
|
}
|
|
|
|
Button("Show Notifications") {
|
|
showNotificationsPopover()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func showAboutPanel() {
|
|
AboutWindowController.shared.show()
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
private func showSettingsPanel() {
|
|
SettingsWindowController.shared.show()
|
|
NSApp.activate(ignoringOtherApps: true)
|
|
}
|
|
|
|
private func applyAppearance() {
|
|
guard let mode = AppearanceMode(rawValue: appearanceMode) else { return }
|
|
switch mode {
|
|
case .system:
|
|
NSApp.appearance = nil
|
|
case .light:
|
|
NSApp.appearance = NSAppearance(named: .aqua)
|
|
case .dark:
|
|
NSApp.appearance = NSAppearance(named: .darkAqua)
|
|
case .auto:
|
|
// Legacy value; treat like system and migrate.
|
|
NSApp.appearance = nil
|
|
appearanceMode = AppearanceMode.system.rawValue
|
|
}
|
|
}
|
|
|
|
private func updateSocketController() {
|
|
let mode = SocketControlSettings.effectiveMode(userMode: currentSocketMode)
|
|
if mode != .off {
|
|
TerminalController.shared.start(
|
|
tabManager: tabManager,
|
|
socketPath: SocketControlSettings.socketPath(),
|
|
accessMode: mode
|
|
)
|
|
} else {
|
|
TerminalController.shared.stop()
|
|
}
|
|
}
|
|
|
|
private var currentSocketMode: SocketControlMode {
|
|
SocketControlMode(rawValue: socketControlMode) ?? SocketControlSettings.defaultMode
|
|
}
|
|
|
|
private var splitRightMenuShortcut: StoredShortcut {
|
|
decodeShortcut(from: splitRightShortcutData, fallback: KeyboardShortcutSettings.Action.splitRight.defaultShortcut)
|
|
}
|
|
|
|
private var splitDownMenuShortcut: StoredShortcut {
|
|
decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut)
|
|
}
|
|
|
|
private var notificationMenuSnapshot: NotificationMenuSnapshot {
|
|
NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications)
|
|
}
|
|
|
|
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
|
|
guard !data.isEmpty,
|
|
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
|
|
return fallback
|
|
}
|
|
return shortcut
|
|
}
|
|
|
|
private func notificationMenuItemTitle(for notification: TerminalNotification) -> String {
|
|
let tabTitle = appDelegate.tabTitle(for: notification.tabId)
|
|
return MenuBarNotificationLineFormatter.menuTitle(notification: notification, tabTitle: tabTitle)
|
|
}
|
|
|
|
private func openNotificationFromMainMenu(_ notification: TerminalNotification) {
|
|
_ = appDelegate.openNotification(
|
|
tabId: notification.tabId,
|
|
surfaceId: notification.surfaceId,
|
|
notificationId: notification.id
|
|
)
|
|
}
|
|
|
|
private func performSplitFromMenu(direction: SplitDirection) {
|
|
if AppDelegate.shared?.performSplitShortcut(direction: direction) == true {
|
|
return
|
|
}
|
|
tabManager.createSplit(direction: direction)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func splitCommandButton(title: String, shortcut: StoredShortcut, action: @escaping () -> Void) -> some View {
|
|
if let key = keyEquivalent(for: shortcut) {
|
|
Button(title, action: action)
|
|
.keyboardShortcut(key, modifiers: eventModifiers(for: shortcut))
|
|
} else {
|
|
Button(title, action: action)
|
|
}
|
|
}
|
|
|
|
private func keyEquivalent(for shortcut: StoredShortcut) -> KeyEquivalent? {
|
|
switch shortcut.key {
|
|
case "←":
|
|
return .leftArrow
|
|
case "→":
|
|
return .rightArrow
|
|
case "↑":
|
|
return .upArrow
|
|
case "↓":
|
|
return .downArrow
|
|
case "\t":
|
|
return .tab
|
|
default:
|
|
let lowered = shortcut.key.lowercased()
|
|
guard lowered.count == 1, let character = lowered.first else { return nil }
|
|
return KeyEquivalent(character)
|
|
}
|
|
}
|
|
|
|
private func eventModifiers(for shortcut: StoredShortcut) -> EventModifiers {
|
|
var modifiers: EventModifiers = []
|
|
if shortcut.command {
|
|
modifiers.insert(.command)
|
|
}
|
|
if shortcut.shift {
|
|
modifiers.insert(.shift)
|
|
}
|
|
if shortcut.option {
|
|
modifiers.insert(.option)
|
|
}
|
|
if shortcut.control {
|
|
modifiers.insert(.control)
|
|
}
|
|
return modifiers
|
|
}
|
|
|
|
private func closePanelOrWindow() {
|
|
if let window = NSApp.keyWindow,
|
|
window.identifier?.rawValue == "cmux.settings" {
|
|
window.performClose(nil)
|
|
return
|
|
}
|
|
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentPanelWithConfirmation()
|
|
}
|
|
|
|
private func closeTabOrWindow() {
|
|
(AppDelegate.shared?.tabManager ?? tabManager).closeCurrentTabWithConfirmation()
|
|
}
|
|
|
|
private func showNotificationsPopover() {
|
|
AppDelegate.shared?.toggleNotificationsPopover(animated: false)
|
|
}
|
|
|
|
private func openAllDebugWindows() {
|
|
SettingsAboutTitlebarDebugWindowController.shared.show()
|
|
SidebarDebugWindowController.shared.show()
|
|
BackgroundDebugWindowController.shared.show()
|
|
MenuBarExtraDebugWindowController.shared.show()
|
|
}
|
|
}
|
|
|
|
private enum SettingsAboutWindowKind: String, CaseIterable, Identifiable {
|
|
case settings
|
|
case about
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayTitle: String {
|
|
switch self {
|
|
case .settings:
|
|
return "Settings Window"
|
|
case .about:
|
|
return "About Window"
|
|
}
|
|
}
|
|
|
|
var windowIdentifier: String {
|
|
switch self {
|
|
case .settings:
|
|
return "cmux.settings"
|
|
case .about:
|
|
return "cmux.about"
|
|
}
|
|
}
|
|
|
|
var fallbackTitle: String {
|
|
switch self {
|
|
case .settings:
|
|
return "Settings"
|
|
case .about:
|
|
return "About cmux"
|
|
}
|
|
}
|
|
|
|
var minimumSize: NSSize {
|
|
switch self {
|
|
case .settings:
|
|
return NSSize(width: 420, height: 360)
|
|
case .about:
|
|
return NSSize(width: 360, height: 520)
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum TitlebarVisibilityOption: String, CaseIterable, Identifiable {
|
|
case hidden
|
|
case visible
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayTitle: String {
|
|
switch self {
|
|
case .hidden:
|
|
return "Hidden"
|
|
case .visible:
|
|
return "Visible"
|
|
}
|
|
}
|
|
|
|
var windowValue: NSWindow.TitleVisibility {
|
|
switch self {
|
|
case .hidden:
|
|
return .hidden
|
|
case .visible:
|
|
return .visible
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum TitlebarToolbarStyleOption: String, CaseIterable, Identifiable {
|
|
case automatic
|
|
case expanded
|
|
case preference
|
|
case unified
|
|
case unifiedCompact
|
|
|
|
var id: String { rawValue }
|
|
|
|
var displayTitle: String {
|
|
switch self {
|
|
case .automatic:
|
|
return "Automatic"
|
|
case .expanded:
|
|
return "Expanded"
|
|
case .preference:
|
|
return "Preference"
|
|
case .unified:
|
|
return "Unified"
|
|
case .unifiedCompact:
|
|
return "Unified Compact"
|
|
}
|
|
}
|
|
|
|
var windowValue: NSWindow.ToolbarStyle {
|
|
switch self {
|
|
case .automatic:
|
|
return .automatic
|
|
case .expanded:
|
|
return .expanded
|
|
case .preference:
|
|
return .preference
|
|
case .unified:
|
|
return .unified
|
|
case .unifiedCompact:
|
|
return .unifiedCompact
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SettingsAboutTitlebarDebugOptions: Equatable {
|
|
var overridesEnabled: Bool
|
|
var windowTitle: String
|
|
var titleVisibility: TitlebarVisibilityOption
|
|
var titlebarAppearsTransparent: Bool
|
|
var movableByWindowBackground: Bool
|
|
var titled: Bool
|
|
var closable: Bool
|
|
var miniaturizable: Bool
|
|
var resizable: Bool
|
|
var fullSizeContentView: Bool
|
|
var showToolbar: Bool
|
|
var toolbarStyle: TitlebarToolbarStyleOption
|
|
|
|
static func defaults(for kind: SettingsAboutWindowKind) -> SettingsAboutTitlebarDebugOptions {
|
|
switch kind {
|
|
case .settings:
|
|
return SettingsAboutTitlebarDebugOptions(
|
|
overridesEnabled: false,
|
|
windowTitle: "Settings",
|
|
titleVisibility: .hidden,
|
|
titlebarAppearsTransparent: true,
|
|
movableByWindowBackground: true,
|
|
titled: true,
|
|
closable: true,
|
|
miniaturizable: true,
|
|
resizable: true,
|
|
fullSizeContentView: true,
|
|
showToolbar: false,
|
|
toolbarStyle: .unifiedCompact
|
|
)
|
|
case .about:
|
|
return SettingsAboutTitlebarDebugOptions(
|
|
overridesEnabled: false,
|
|
windowTitle: "About cmux",
|
|
titleVisibility: .hidden,
|
|
titlebarAppearsTransparent: true,
|
|
movableByWindowBackground: false,
|
|
titled: true,
|
|
closable: true,
|
|
miniaturizable: true,
|
|
resizable: false,
|
|
fullSizeContentView: false,
|
|
showToolbar: false,
|
|
toolbarStyle: .automatic
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class SettingsAboutTitlebarDebugStore: ObservableObject {
|
|
static let shared = SettingsAboutTitlebarDebugStore()
|
|
|
|
@Published var settingsOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .settings) {
|
|
didSet { applyToOpenWindows(for: .settings) }
|
|
}
|
|
@Published var aboutOptions = SettingsAboutTitlebarDebugOptions.defaults(for: .about) {
|
|
didSet { applyToOpenWindows(for: .about) }
|
|
}
|
|
|
|
private init() {}
|
|
|
|
func options(for kind: SettingsAboutWindowKind) -> SettingsAboutTitlebarDebugOptions {
|
|
switch kind {
|
|
case .settings:
|
|
return settingsOptions
|
|
case .about:
|
|
return aboutOptions
|
|
}
|
|
}
|
|
|
|
func update(_ newValue: SettingsAboutTitlebarDebugOptions, for kind: SettingsAboutWindowKind) {
|
|
switch kind {
|
|
case .settings:
|
|
settingsOptions = newValue
|
|
case .about:
|
|
aboutOptions = newValue
|
|
}
|
|
}
|
|
|
|
func reset(_ kind: SettingsAboutWindowKind) {
|
|
update(SettingsAboutTitlebarDebugOptions.defaults(for: kind), for: kind)
|
|
}
|
|
|
|
func applyToOpenWindows(for kind: SettingsAboutWindowKind) {
|
|
for window in NSApp.windows where window.identifier?.rawValue == kind.windowIdentifier {
|
|
apply(options(for: kind), to: window, for: kind)
|
|
}
|
|
}
|
|
|
|
func applyToOpenWindows() {
|
|
applyToOpenWindows(for: .settings)
|
|
applyToOpenWindows(for: .about)
|
|
}
|
|
|
|
func applyCurrentOptions(to window: NSWindow, for kind: SettingsAboutWindowKind) {
|
|
apply(options(for: kind), to: window, for: kind)
|
|
}
|
|
|
|
func copyConfigToPasteboard() {
|
|
let settings = options(for: .settings)
|
|
let about = options(for: .about)
|
|
let payload = """
|
|
# Settings/About Titlebar Debug
|
|
settings.overridesEnabled=\(settings.overridesEnabled)
|
|
settings.title=\(settings.windowTitle)
|
|
settings.titleVisibility=\(settings.titleVisibility.rawValue)
|
|
settings.titlebarAppearsTransparent=\(settings.titlebarAppearsTransparent)
|
|
settings.movableByWindowBackground=\(settings.movableByWindowBackground)
|
|
settings.titled=\(settings.titled)
|
|
settings.closable=\(settings.closable)
|
|
settings.miniaturizable=\(settings.miniaturizable)
|
|
settings.resizable=\(settings.resizable)
|
|
settings.fullSizeContentView=\(settings.fullSizeContentView)
|
|
settings.showToolbar=\(settings.showToolbar)
|
|
settings.toolbarStyle=\(settings.toolbarStyle.rawValue)
|
|
about.overridesEnabled=\(about.overridesEnabled)
|
|
about.title=\(about.windowTitle)
|
|
about.titleVisibility=\(about.titleVisibility.rawValue)
|
|
about.titlebarAppearsTransparent=\(about.titlebarAppearsTransparent)
|
|
about.movableByWindowBackground=\(about.movableByWindowBackground)
|
|
about.titled=\(about.titled)
|
|
about.closable=\(about.closable)
|
|
about.miniaturizable=\(about.miniaturizable)
|
|
about.resizable=\(about.resizable)
|
|
about.fullSizeContentView=\(about.fullSizeContentView)
|
|
about.showToolbar=\(about.showToolbar)
|
|
about.toolbarStyle=\(about.toolbarStyle.rawValue)
|
|
"""
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(payload, forType: .string)
|
|
}
|
|
|
|
private func apply(_ options: SettingsAboutTitlebarDebugOptions, to window: NSWindow, for kind: SettingsAboutWindowKind) {
|
|
let effective = options.overridesEnabled ? options : SettingsAboutTitlebarDebugOptions.defaults(for: kind)
|
|
let resolvedTitle = effective.windowTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
window.title = resolvedTitle.isEmpty ? kind.fallbackTitle : resolvedTitle
|
|
window.titleVisibility = effective.titleVisibility.windowValue
|
|
window.titlebarAppearsTransparent = effective.titlebarAppearsTransparent
|
|
window.isMovableByWindowBackground = effective.movableByWindowBackground
|
|
window.toolbarStyle = effective.toolbarStyle.windowValue
|
|
|
|
if effective.showToolbar {
|
|
ensureToolbar(on: window, kind: kind)
|
|
} else if window.toolbar != nil {
|
|
window.toolbar = nil
|
|
}
|
|
|
|
var styleMask = window.styleMask
|
|
setStyleMaskBit(&styleMask, .titled, enabled: effective.titled)
|
|
setStyleMaskBit(&styleMask, .closable, enabled: effective.closable)
|
|
setStyleMaskBit(&styleMask, .miniaturizable, enabled: effective.miniaturizable)
|
|
setStyleMaskBit(&styleMask, .resizable, enabled: effective.resizable)
|
|
setStyleMaskBit(&styleMask, .fullSizeContentView, enabled: effective.fullSizeContentView)
|
|
window.styleMask = styleMask
|
|
|
|
let maxSize = effective.resizable ? NSSize(width: 8192, height: 8192) : kind.minimumSize
|
|
window.minSize = kind.minimumSize
|
|
window.maxSize = maxSize
|
|
window.contentMinSize = kind.minimumSize
|
|
window.contentMaxSize = maxSize
|
|
window.invalidateShadow()
|
|
AppDelegate.shared?.applyWindowDecorations(to: window)
|
|
}
|
|
|
|
private func ensureToolbar(on window: NSWindow, kind: SettingsAboutWindowKind) {
|
|
guard window.toolbar == nil else { return }
|
|
let identifier = NSToolbar.Identifier("cmux.debug.titlebar.\(kind.rawValue)")
|
|
let toolbar = NSToolbar(identifier: identifier)
|
|
toolbar.allowsUserCustomization = false
|
|
toolbar.autosavesConfiguration = false
|
|
toolbar.displayMode = .iconOnly
|
|
toolbar.showsBaselineSeparator = false
|
|
window.toolbar = toolbar
|
|
}
|
|
|
|
private func setStyleMaskBit(
|
|
_ styleMask: inout NSWindow.StyleMask,
|
|
_ bit: NSWindow.StyleMask,
|
|
enabled: Bool
|
|
) {
|
|
if enabled {
|
|
styleMask.insert(bit)
|
|
} else {
|
|
styleMask.remove(bit)
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class SettingsAboutTitlebarDebugWindowController: NSWindowController, NSWindowDelegate {
|
|
static let shared = SettingsAboutTitlebarDebugWindowController()
|
|
|
|
private init() {
|
|
let window = NSPanel(
|
|
contentRect: NSRect(x: 0, y: 0, width: 470, height: 690),
|
|
styleMask: [.titled, .closable, .resizable, .utilityWindow],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Settings/About Titlebar Debug"
|
|
window.titleVisibility = .visible
|
|
window.titlebarAppearsTransparent = false
|
|
window.isMovableByWindowBackground = true
|
|
window.isReleasedWhenClosed = false
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.settingsAboutTitlebarDebug")
|
|
window.center()
|
|
window.contentView = NSHostingView(rootView: SettingsAboutTitlebarDebugView())
|
|
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)
|
|
SettingsAboutTitlebarDebugStore.shared.applyToOpenWindows()
|
|
}
|
|
}
|
|
|
|
private struct SettingsAboutTitlebarDebugView: View {
|
|
@ObservedObject private var store = SettingsAboutTitlebarDebugStore.shared
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Settings/About Titlebar Debug")
|
|
.font(.headline)
|
|
|
|
editor(for: .settings)
|
|
editor(for: .about)
|
|
|
|
GroupBox("Actions") {
|
|
HStack(spacing: 10) {
|
|
Button("Reset All") {
|
|
store.reset(.settings)
|
|
store.reset(.about)
|
|
}
|
|
Button("Reapply to Open Windows") {
|
|
store.applyToOpenWindows()
|
|
}
|
|
Button("Copy Config") {
|
|
store.copyConfigToPasteboard()
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
|
|
private func editor(for kind: SettingsAboutWindowKind) -> some View {
|
|
let overridesEnabled = binding(for: kind, keyPath: \.overridesEnabled)
|
|
|
|
return GroupBox(kind.displayTitle) {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Toggle("Enable Debug Overrides", isOn: overridesEnabled)
|
|
|
|
Text("When disabled, cmux uses normal default titlebar behavior for this window.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Divider()
|
|
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack(spacing: 8) {
|
|
Text("Window Title")
|
|
TextField("", text: binding(for: kind, keyPath: \.windowTitle))
|
|
}
|
|
|
|
HStack(spacing: 10) {
|
|
Picker("Title Visibility", selection: binding(for: kind, keyPath: \.titleVisibility)) {
|
|
ForEach(TitlebarVisibilityOption.allCases) { option in
|
|
Text(option.displayTitle).tag(option)
|
|
}
|
|
}
|
|
Picker("Toolbar Style", selection: binding(for: kind, keyPath: \.toolbarStyle)) {
|
|
ForEach(TitlebarToolbarStyleOption.allCases) { option in
|
|
Text(option.displayTitle).tag(option)
|
|
}
|
|
}
|
|
}
|
|
|
|
Toggle("Show Toolbar", isOn: binding(for: kind, keyPath: \.showToolbar))
|
|
Toggle("Transparent Titlebar", isOn: binding(for: kind, keyPath: \.titlebarAppearsTransparent))
|
|
Toggle("Movable by Window Background", isOn: binding(for: kind, keyPath: \.movableByWindowBackground))
|
|
|
|
Divider()
|
|
|
|
Text("Style Mask")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Toggle("Titled", isOn: binding(for: kind, keyPath: \.titled))
|
|
Toggle("Closable", isOn: binding(for: kind, keyPath: \.closable))
|
|
Toggle("Miniaturizable", isOn: binding(for: kind, keyPath: \.miniaturizable))
|
|
Toggle("Resizable", isOn: binding(for: kind, keyPath: \.resizable))
|
|
Toggle("Full Size Content View", isOn: binding(for: kind, keyPath: \.fullSizeContentView))
|
|
|
|
HStack(spacing: 10) {
|
|
Button("Reset \(kind == .settings ? "Settings" : "About")") {
|
|
store.reset(kind)
|
|
}
|
|
Button("Apply Now") {
|
|
store.applyToOpenWindows(for: kind)
|
|
}
|
|
}
|
|
}
|
|
.disabled(!overridesEnabled.wrappedValue)
|
|
.opacity(overridesEnabled.wrappedValue ? 1 : 0.75)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
}
|
|
|
|
private func binding<Value>(
|
|
for kind: SettingsAboutWindowKind,
|
|
keyPath: WritableKeyPath<SettingsAboutTitlebarDebugOptions, Value>
|
|
) -> Binding<Value> {
|
|
Binding(
|
|
get: { store.options(for: kind)[keyPath: keyPath] },
|
|
set: { newValue in
|
|
var updated = store.options(for: kind)
|
|
updated[keyPath: keyPath] = newValue
|
|
store.update(updated, for: kind)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private enum DebugWindowConfigSnapshot {
|
|
static func copyCombinedToPasteboard(defaults: UserDefaults = .standard) {
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(combinedPayload(defaults: defaults), forType: .string)
|
|
}
|
|
|
|
static func combinedPayload(defaults: UserDefaults = .standard) -> String {
|
|
let sidebarPayload = """
|
|
sidebarPreset=\(stringValue(defaults, key: "sidebarPreset", fallback: SidebarPresetOption.nativeSidebar.rawValue))
|
|
sidebarMaterial=\(stringValue(defaults, key: "sidebarMaterial", fallback: SidebarMaterialOption.sidebar.rawValue))
|
|
sidebarBlendMode=\(stringValue(defaults, key: "sidebarBlendMode", fallback: SidebarBlendModeOption.withinWindow.rawValue))
|
|
sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue))
|
|
sidebarBlurOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarBlurOpacity", fallback: 1.0)))
|
|
sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000"))
|
|
sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18)))
|
|
sidebarCornerRadius=\(String(format: "%.1f", doubleValue(defaults, key: "sidebarCornerRadius", fallback: 0.0)))
|
|
shortcutHintSidebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintXKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintX)))
|
|
shortcutHintSidebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.sidebarHintYKey, fallback: ShortcutHintDebugSettings.defaultSidebarHintY)))
|
|
shortcutHintTitlebarXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintXKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintX)))
|
|
shortcutHintTitlebarYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.titlebarHintYKey, fallback: ShortcutHintDebugSettings.defaultTitlebarHintY)))
|
|
shortcutHintPaneTabXOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintXKey, fallback: ShortcutHintDebugSettings.defaultPaneHintX)))
|
|
shortcutHintPaneTabYOffset=\(String(format: "%.1f", doubleValue(defaults, key: ShortcutHintDebugSettings.paneHintYKey, fallback: ShortcutHintDebugSettings.defaultPaneHintY)))
|
|
shortcutHintAlwaysShow=\(boolValue(defaults, key: ShortcutHintDebugSettings.alwaysShowHintsKey, fallback: ShortcutHintDebugSettings.defaultAlwaysShowHints))
|
|
"""
|
|
|
|
let backgroundPayload = """
|
|
bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: true))
|
|
bgGlassMaterial=\(stringValue(defaults, key: "bgGlassMaterial", fallback: "hudWindow"))
|
|
bgGlassTintHex=\(stringValue(defaults, key: "bgGlassTintHex", fallback: "#000000"))
|
|
bgGlassTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "bgGlassTintOpacity", fallback: 0.03)))
|
|
"""
|
|
|
|
let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults)
|
|
|
|
return """
|
|
# Sidebar Debug
|
|
\(sidebarPayload)
|
|
|
|
# Background Debug
|
|
\(backgroundPayload)
|
|
|
|
# Menu Bar Extra Debug
|
|
\(menuBarPayload)
|
|
"""
|
|
}
|
|
|
|
private static func stringValue(_ defaults: UserDefaults, key: String, fallback: String) -> String {
|
|
defaults.string(forKey: key) ?? fallback
|
|
}
|
|
|
|
private static func doubleValue(_ defaults: UserDefaults, key: String, fallback: Double) -> Double {
|
|
if let value = defaults.object(forKey: key) as? NSNumber {
|
|
return value.doubleValue
|
|
}
|
|
if let text = defaults.string(forKey: key), let parsed = Double(text) {
|
|
return parsed
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
private static func boolValue(_ defaults: UserDefaults, key: String, fallback: Bool) -> Bool {
|
|
guard defaults.object(forKey: key) != nil else { return fallback }
|
|
return defaults.bool(forKey: key)
|
|
}
|
|
}
|
|
|
|
private final class DebugWindowControlsWindowController: NSWindowController, NSWindowDelegate {
|
|
static let shared = DebugWindowControlsWindowController()
|
|
|
|
private init() {
|
|
let window = NSPanel(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 560),
|
|
styleMask: [.titled, .closable, .utilityWindow],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Debug Window Controls"
|
|
window.titleVisibility = .visible
|
|
window.titlebarAppearsTransparent = false
|
|
window.isMovableByWindowBackground = true
|
|
window.isReleasedWhenClosed = false
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.debugWindowControls")
|
|
window.center()
|
|
window.contentView = NSHostingView(rootView: DebugWindowControlsView())
|
|
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 DebugWindowControlsView: View {
|
|
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
|
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
|
@AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
|
|
@AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
|
|
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
|
|
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
|
|
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Debug Window Controls")
|
|
.font(.headline)
|
|
|
|
GroupBox("Open") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Button("Settings/About Titlebar Debug…") {
|
|
SettingsAboutTitlebarDebugWindowController.shared.show()
|
|
}
|
|
Button("Sidebar Debug…") {
|
|
SidebarDebugWindowController.shared.show()
|
|
}
|
|
Button("Background Debug…") {
|
|
BackgroundDebugWindowController.shared.show()
|
|
}
|
|
Button("Menu Bar Extra Debug…") {
|
|
MenuBarExtraDebugWindowController.shared.show()
|
|
}
|
|
Button("Open All Debug Windows") {
|
|
SettingsAboutTitlebarDebugWindowController.shared.show()
|
|
SidebarDebugWindowController.shared.show()
|
|
BackgroundDebugWindowController.shared.show()
|
|
MenuBarExtraDebugWindowController.shared.show()
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox("Shortcut Hints") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints)
|
|
|
|
hintOffsetSection(
|
|
"Sidebar Cmd+1…9",
|
|
x: $sidebarShortcutHintXOffset,
|
|
y: $sidebarShortcutHintYOffset
|
|
)
|
|
|
|
hintOffsetSection(
|
|
"Titlebar Buttons",
|
|
x: $titlebarShortcutHintXOffset,
|
|
y: $titlebarShortcutHintYOffset
|
|
)
|
|
|
|
hintOffsetSection(
|
|
"Pane Ctrl/Cmd+1…9",
|
|
x: $paneShortcutHintXOffset,
|
|
y: $paneShortcutHintYOffset
|
|
)
|
|
|
|
HStack(spacing: 12) {
|
|
Button("Reset Hints") {
|
|
resetShortcutHintOffsets()
|
|
}
|
|
Button("Copy Hint Config") {
|
|
copyShortcutHintConfig()
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox("Copy") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Button("Copy All Debug Config") {
|
|
DebugWindowConfigSnapshot.copyCombinedToPasteboard()
|
|
}
|
|
Text("Copies sidebar, background, and menu bar debug settings as one payload.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
|
|
private func hintOffsetSection(_ title: String, x: Binding<Double>, y: Binding<Double>) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
sliderRow("X", value: x)
|
|
sliderRow("Y", value: y)
|
|
}
|
|
}
|
|
|
|
private func sliderRow(_ label: String, value: Binding<Double>) -> some View {
|
|
HStack(spacing: 8) {
|
|
Text(label)
|
|
Slider(value: value, in: ShortcutHintDebugSettings.offsetRange)
|
|
Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue)))
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.frame(width: 44, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
private func resetShortcutHintOffsets() {
|
|
sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
|
sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
|
titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
|
|
titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
|
|
paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
|
|
paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
|
|
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
}
|
|
|
|
private func copyShortcutHintConfig() {
|
|
let payload = """
|
|
shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset)))
|
|
shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)))
|
|
shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)))
|
|
shortcutHintTitlebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset)))
|
|
shortcutHintPaneTabXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintXOffset)))
|
|
shortcutHintPaneTabYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintYOffset)))
|
|
shortcutHintAlwaysShow=\(alwaysShowShortcutHints)
|
|
"""
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(payload, forType: .string)
|
|
}
|
|
}
|
|
|
|
private final class AboutWindowController: NSWindowController, NSWindowDelegate {
|
|
static let shared = AboutWindowController()
|
|
|
|
private init() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 360, height: 520),
|
|
styleMask: [.titled, .closable, .miniaturizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.isReleasedWhenClosed = false
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.about")
|
|
window.center()
|
|
window.contentView = NSHostingView(rootView: AboutPanelView())
|
|
SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about)
|
|
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() {
|
|
guard let window else { return }
|
|
SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .about)
|
|
window.center()
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
}
|
|
|
|
private final class AcknowledgmentsWindowController: NSWindowController, NSWindowDelegate {
|
|
static let shared = AcknowledgmentsWindowController()
|
|
|
|
private init() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 500, height: 480),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.isReleasedWhenClosed = false
|
|
window.title = "Third-Party Licenses"
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.licenses")
|
|
window.center()
|
|
window.contentView = NSHostingView(rootView: AcknowledgmentsView())
|
|
super.init(window: window)
|
|
window.delegate = self
|
|
}
|
|
|
|
@available(*, unavailable)
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func show() {
|
|
guard let window else { return }
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
}
|
|
|
|
private struct AcknowledgmentsView: View {
|
|
private let content: String = {
|
|
if let url = Bundle.main.url(forResource: "THIRD_PARTY_LICENSES", withExtension: "md"),
|
|
let text = try? String(contentsOf: url) {
|
|
return text
|
|
}
|
|
return "Licenses file not found."
|
|
}()
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
Text(content)
|
|
.font(.system(.body, design: .monospaced))
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class SettingsWindowController: NSWindowController, NSWindowDelegate {
|
|
static let shared = SettingsWindowController()
|
|
|
|
private init() {
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 640, height: 520),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.isReleasedWhenClosed = false
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
|
|
window.center()
|
|
window.contentView = NSHostingView(rootView: SettingsRootView())
|
|
SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings)
|
|
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() {
|
|
guard let window else { return }
|
|
SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings)
|
|
if !window.isVisible {
|
|
window.center()
|
|
}
|
|
window.makeKeyAndOrderFront(nil)
|
|
}
|
|
}
|
|
|
|
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
|
|
|
|
private let githubURL = URL(string: "https://github.com/manaflow-ai/cmux")
|
|
private let docsURL = URL(string: "https://cmux.dev/docs")
|
|
|
|
private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String }
|
|
private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String }
|
|
private var commit: String? {
|
|
if let value = Bundle.main.infoDictionary?["CMUXCommit"] as? String, !value.isEmpty {
|
|
return value
|
|
}
|
|
let env = ProcessInfo.processInfo.environment["CMUX_COMMIT"] ?? ""
|
|
return env.isEmpty ? nil : env
|
|
}
|
|
private var copyright: String? { Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .center) {
|
|
Image(nsImage: NSApplication.shared.applicationIconImage)
|
|
.resizable()
|
|
.renderingMode(.original)
|
|
.frame(width: 96, height: 96)
|
|
.shadow(color: .black.opacity(0.18), radius: 8, x: 0, y: 3)
|
|
|
|
VStack(alignment: .center, spacing: 32) {
|
|
VStack(alignment: .center, spacing: 8) {
|
|
Text("cmux")
|
|
.bold()
|
|
.font(.title)
|
|
Text("A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS.")
|
|
.multilineTextAlignment(.center)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.font(.caption)
|
|
.tint(.secondary)
|
|
.opacity(0.8)
|
|
}
|
|
.textSelection(.enabled)
|
|
|
|
VStack(spacing: 2) {
|
|
if let version {
|
|
AboutPropertyRow(label: "Version", text: version)
|
|
}
|
|
if let build {
|
|
AboutPropertyRow(label: "Build", text: build)
|
|
}
|
|
let commitText = commit ?? "—"
|
|
let commitURL = commit.flatMap { hash in
|
|
URL(string: "https://github.com/manaflow-ai/cmux/commit/\(hash)")
|
|
}
|
|
AboutPropertyRow(label: "Commit", text: commitText, url: commitURL)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
HStack(spacing: 8) {
|
|
if let url = docsURL {
|
|
Button("Docs") {
|
|
openURL(url)
|
|
}
|
|
}
|
|
if let url = githubURL {
|
|
Button("GitHub") {
|
|
openURL(url)
|
|
}
|
|
}
|
|
Button("Licenses") {
|
|
AcknowledgmentsWindowController.shared.show()
|
|
}
|
|
}
|
|
|
|
if let copy = copyright, !copy.isEmpty {
|
|
Text(copy)
|
|
.font(.caption)
|
|
.textSelection(.enabled)
|
|
.tint(.secondary)
|
|
.opacity(0.8)
|
|
.multilineTextAlignment(.center)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.padding(.top, 8)
|
|
.padding(32)
|
|
.frame(minWidth: 280)
|
|
.background(AboutVisualEffectBackground(material: .underWindowBackground).ignoresSafeArea())
|
|
}
|
|
}
|
|
|
|
private struct SidebarDebugView: View {
|
|
@AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue
|
|
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.18
|
|
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#000000"
|
|
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
|
|
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
|
|
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
|
|
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
|
|
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0
|
|
@AppStorage(ShortcutHintDebugSettings.sidebarHintXKey) private var sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
|
@AppStorage(ShortcutHintDebugSettings.sidebarHintYKey) private var sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
|
@AppStorage(ShortcutHintDebugSettings.titlebarHintXKey) private var titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
|
|
@AppStorage(ShortcutHintDebugSettings.titlebarHintYKey) private var titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
|
|
@AppStorage(ShortcutHintDebugSettings.paneHintXKey) private var paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
|
|
@AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
|
|
@AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
|
|
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)
|
|
}
|
|
|
|
GroupBox("Shortcut Hints") {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Toggle("Always show shortcut hints", isOn: $alwaysShowShortcutHints)
|
|
|
|
hintOffsetSection(
|
|
"Sidebar Cmd+1…9",
|
|
x: $sidebarShortcutHintXOffset,
|
|
y: $sidebarShortcutHintYOffset
|
|
)
|
|
|
|
hintOffsetSection(
|
|
"Titlebar Buttons",
|
|
x: $titlebarShortcutHintXOffset,
|
|
y: $titlebarShortcutHintYOffset
|
|
)
|
|
|
|
hintOffsetSection(
|
|
"Pane Ctrl/Cmd+1…9",
|
|
x: $paneShortcutHintXOffset,
|
|
y: $paneShortcutHintYOffset
|
|
)
|
|
}
|
|
.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("Reset Hints") {
|
|
resetShortcutHintOffsets()
|
|
}
|
|
}
|
|
|
|
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 hintOffsetSection(_ title: String, x: Binding<Double>, y: Binding<Double>) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
sliderRow("X", value: x)
|
|
sliderRow("Y", value: y)
|
|
}
|
|
}
|
|
|
|
private func sliderRow(_ label: String, value: Binding<Double>) -> some View {
|
|
HStack(spacing: 8) {
|
|
Text(label)
|
|
Slider(value: value, in: ShortcutHintDebugSettings.offsetRange)
|
|
Text(String(format: "%.1f", ShortcutHintDebugSettings.clamped(value.wrappedValue)))
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.frame(width: 44, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
private func resetShortcutHintOffsets() {
|
|
sidebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultSidebarHintX
|
|
sidebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultSidebarHintY
|
|
titlebarShortcutHintXOffset = ShortcutHintDebugSettings.defaultTitlebarHintX
|
|
titlebarShortcutHintYOffset = ShortcutHintDebugSettings.defaultTitlebarHintY
|
|
paneShortcutHintXOffset = ShortcutHintDebugSettings.defaultPaneHintX
|
|
paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY
|
|
alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints
|
|
}
|
|
|
|
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))
|
|
shortcutHintSidebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintXOffset)))
|
|
shortcutHintSidebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(sidebarShortcutHintYOffset)))
|
|
shortcutHintTitlebarXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintXOffset)))
|
|
shortcutHintTitlebarYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(titlebarShortcutHintYOffset)))
|
|
shortcutHintPaneTabXOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintXOffset)))
|
|
shortcutHintPaneTabYOffset=\(String(format: "%.1f", ShortcutHintDebugSettings.clamped(paneShortcutHintYOffset)))
|
|
shortcutHintAlwaysShow=\(alwaysShowShortcutHints)
|
|
"""
|
|
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: - Menu Bar Extra Debug Window
|
|
|
|
private final class MenuBarExtraDebugWindowController: NSWindowController, NSWindowDelegate {
|
|
static let shared = MenuBarExtraDebugWindowController()
|
|
|
|
private init() {
|
|
let window = NSPanel(
|
|
contentRect: NSRect(x: 0, y: 0, width: 420, height: 430),
|
|
styleMask: [.titled, .closable, .utilityWindow],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.title = "Menu Bar Extra Debug"
|
|
window.titleVisibility = .visible
|
|
window.titlebarAppearsTransparent = false
|
|
window.isMovableByWindowBackground = true
|
|
window.isReleasedWhenClosed = false
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.menubarDebug")
|
|
window.center()
|
|
window.contentView = NSHostingView(rootView: MenuBarExtraDebugView())
|
|
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 MenuBarExtraDebugView: View {
|
|
@AppStorage(MenuBarIconDebugSettings.previewEnabledKey) private var previewEnabled = false
|
|
@AppStorage(MenuBarIconDebugSettings.previewCountKey) private var previewCount = 1
|
|
@AppStorage(MenuBarIconDebugSettings.badgeRectXKey) private var badgeRectX = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.x)
|
|
@AppStorage(MenuBarIconDebugSettings.badgeRectYKey) private var badgeRectY = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.y)
|
|
@AppStorage(MenuBarIconDebugSettings.badgeRectWidthKey) private var badgeRectWidth = Double(MenuBarIconDebugSettings.defaultBadgeRect.width)
|
|
@AppStorage(MenuBarIconDebugSettings.badgeRectHeightKey) private var badgeRectHeight = Double(MenuBarIconDebugSettings.defaultBadgeRect.height)
|
|
@AppStorage(MenuBarIconDebugSettings.singleDigitFontSizeKey) private var singleDigitFontSize = Double(MenuBarIconDebugSettings.defaultSingleDigitFontSize)
|
|
@AppStorage(MenuBarIconDebugSettings.multiDigitFontSizeKey) private var multiDigitFontSize = Double(MenuBarIconDebugSettings.defaultMultiDigitFontSize)
|
|
@AppStorage(MenuBarIconDebugSettings.singleDigitYOffsetKey) private var singleDigitYOffset = Double(MenuBarIconDebugSettings.defaultSingleDigitYOffset)
|
|
@AppStorage(MenuBarIconDebugSettings.multiDigitYOffsetKey) private var multiDigitYOffset = Double(MenuBarIconDebugSettings.defaultMultiDigitYOffset)
|
|
@AppStorage(MenuBarIconDebugSettings.singleDigitXAdjustKey) private var singleDigitXAdjust = Double(MenuBarIconDebugSettings.defaultSingleDigitXAdjust)
|
|
@AppStorage(MenuBarIconDebugSettings.multiDigitXAdjustKey) private var multiDigitXAdjust = Double(MenuBarIconDebugSettings.defaultMultiDigitXAdjust)
|
|
@AppStorage(MenuBarIconDebugSettings.textRectWidthAdjustKey) private var textRectWidthAdjust = Double(MenuBarIconDebugSettings.defaultTextRectWidthAdjust)
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Menu Bar Extra Icon")
|
|
.font(.headline)
|
|
|
|
GroupBox("Preview Count") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Toggle("Override unread count", isOn: $previewEnabled)
|
|
|
|
Stepper(value: $previewCount, in: 0...99) {
|
|
HStack {
|
|
Text("Unread Count")
|
|
Spacer()
|
|
Text("\(previewCount)")
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
.disabled(!previewEnabled)
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox("Badge Rect") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
sliderRow("X", value: $badgeRectX, range: 0...20, format: "%.2f")
|
|
sliderRow("Y", value: $badgeRectY, range: 0...20, format: "%.2f")
|
|
sliderRow("Width", value: $badgeRectWidth, range: 4...14, format: "%.2f")
|
|
sliderRow("Height", value: $badgeRectHeight, range: 4...14, format: "%.2f")
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
GroupBox("Badge Text") {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
sliderRow("1-digit size", value: $singleDigitFontSize, range: 6...14, format: "%.2f")
|
|
sliderRow("2-digit size", value: $multiDigitFontSize, range: 6...14, format: "%.2f")
|
|
sliderRow("1-digit X", value: $singleDigitXAdjust, range: -4...4, format: "%.2f")
|
|
sliderRow("2-digit X", value: $multiDigitXAdjust, range: -4...4, format: "%.2f")
|
|
sliderRow("1-digit Y", value: $singleDigitYOffset, range: -3...4, format: "%.2f")
|
|
sliderRow("2-digit Y", value: $multiDigitYOffset, range: -3...4, format: "%.2f")
|
|
sliderRow("Text width adjust", value: $textRectWidthAdjust, range: -3...5, format: "%.2f")
|
|
}
|
|
.padding(.top, 2)
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
Button("Reset") {
|
|
previewEnabled = false
|
|
previewCount = 1
|
|
badgeRectX = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.x)
|
|
badgeRectY = Double(MenuBarIconDebugSettings.defaultBadgeRect.origin.y)
|
|
badgeRectWidth = Double(MenuBarIconDebugSettings.defaultBadgeRect.width)
|
|
badgeRectHeight = Double(MenuBarIconDebugSettings.defaultBadgeRect.height)
|
|
singleDigitFontSize = Double(MenuBarIconDebugSettings.defaultSingleDigitFontSize)
|
|
multiDigitFontSize = Double(MenuBarIconDebugSettings.defaultMultiDigitFontSize)
|
|
singleDigitYOffset = Double(MenuBarIconDebugSettings.defaultSingleDigitYOffset)
|
|
multiDigitYOffset = Double(MenuBarIconDebugSettings.defaultMultiDigitYOffset)
|
|
singleDigitXAdjust = Double(MenuBarIconDebugSettings.defaultSingleDigitXAdjust)
|
|
multiDigitXAdjust = Double(MenuBarIconDebugSettings.defaultMultiDigitXAdjust)
|
|
textRectWidthAdjust = Double(MenuBarIconDebugSettings.defaultTextRectWidthAdjust)
|
|
applyLiveUpdate()
|
|
}
|
|
|
|
Button("Copy Config") {
|
|
let payload = MenuBarIconDebugSettings.copyPayload()
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(payload, forType: .string)
|
|
}
|
|
}
|
|
|
|
Text("Tip: enable override count, then tune until the menu bar icon looks right.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
}
|
|
.onAppear { applyLiveUpdate() }
|
|
.onChange(of: previewEnabled) { _ in applyLiveUpdate() }
|
|
.onChange(of: previewCount) { _ in applyLiveUpdate() }
|
|
.onChange(of: badgeRectX) { _ in applyLiveUpdate() }
|
|
.onChange(of: badgeRectY) { _ in applyLiveUpdate() }
|
|
.onChange(of: badgeRectWidth) { _ in applyLiveUpdate() }
|
|
.onChange(of: badgeRectHeight) { _ in applyLiveUpdate() }
|
|
.onChange(of: singleDigitFontSize) { _ in applyLiveUpdate() }
|
|
.onChange(of: multiDigitFontSize) { _ in applyLiveUpdate() }
|
|
.onChange(of: singleDigitXAdjust) { _ in applyLiveUpdate() }
|
|
.onChange(of: multiDigitXAdjust) { _ in applyLiveUpdate() }
|
|
.onChange(of: singleDigitYOffset) { _ in applyLiveUpdate() }
|
|
.onChange(of: multiDigitYOffset) { _ in applyLiveUpdate() }
|
|
.onChange(of: textRectWidthAdjust) { _ in applyLiveUpdate() }
|
|
}
|
|
|
|
private func sliderRow(
|
|
_ label: String,
|
|
value: Binding<Double>,
|
|
range: ClosedRange<Double>,
|
|
format: String
|
|
) -> some View {
|
|
HStack(spacing: 8) {
|
|
Text(label)
|
|
Slider(value: value, in: range)
|
|
Text(String(format: format, value.wrappedValue))
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.frame(width: 58, alignment: .trailing)
|
|
}
|
|
}
|
|
|
|
private func applyLiveUpdate() {
|
|
AppDelegate.shared?.refreshMenuBarExtraForDebug()
|
|
}
|
|
}
|
|
|
|
// 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.03
|
|
@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.03
|
|
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() {
|
|
let window: NSWindow? = {
|
|
if let key = NSApp.keyWindow,
|
|
let raw = key.identifier?.rawValue,
|
|
raw == "cmux.main" || raw.hasPrefix("cmux.main.") {
|
|
return key
|
|
}
|
|
return NSApp.windows.first(where: {
|
|
guard let raw = $0.identifier?.rawValue else { return false }
|
|
return raw == "cmux.main" || raw.hasPrefix("cmux.main.")
|
|
})
|
|
}()
|
|
guard let window 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
|
|
private let url: URL?
|
|
|
|
init(label: String, text: String, url: URL? = nil) {
|
|
self.label = label
|
|
self.text = text
|
|
self.url = url
|
|
}
|
|
|
|
@ViewBuilder private var textView: some View {
|
|
Text(text)
|
|
.frame(width: 140, alignment: .leading)
|
|
.padding(.leading, 2)
|
|
.tint(.secondary)
|
|
.opacity(0.8)
|
|
.monospaced()
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
Text(label)
|
|
.frame(width: 126, alignment: .trailing)
|
|
.padding(.trailing, 2)
|
|
if let url {
|
|
Link(destination: url) {
|
|
textView
|
|
}
|
|
} else {
|
|
textView
|
|
}
|
|
}
|
|
.font(.callout)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
private struct AboutVisualEffectBackground: NSViewRepresentable {
|
|
let material: NSVisualEffectView.Material
|
|
let blendingMode: NSVisualEffectView.BlendingMode
|
|
let isEmphasized: Bool
|
|
|
|
init(
|
|
material: NSVisualEffectView.Material,
|
|
blendingMode: NSVisualEffectView.BlendingMode = .behindWindow,
|
|
isEmphasized: Bool = false
|
|
) {
|
|
self.material = material
|
|
self.blendingMode = blendingMode
|
|
self.isEmphasized = isEmphasized
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {
|
|
nsView.material = material
|
|
nsView.blendingMode = blendingMode
|
|
nsView.isEmphasized = isEmphasized
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSVisualEffectView {
|
|
let visualEffect = NSVisualEffectView()
|
|
visualEffect.autoresizingMask = [.width, .height]
|
|
return visualEffect
|
|
}
|
|
}
|
|
|
|
enum AppearanceMode: String, CaseIterable, Identifiable {
|
|
case system
|
|
case light
|
|
case dark
|
|
case auto
|
|
|
|
var id: String { rawValue }
|
|
|
|
static var visibleCases: [AppearanceMode] {
|
|
[.system, .light, .dark]
|
|
}
|
|
|
|
var displayName: String {
|
|
switch self {
|
|
case .system:
|
|
return "System"
|
|
case .light:
|
|
return "Light"
|
|
case .dark:
|
|
return "Dark"
|
|
case .auto:
|
|
return "Auto"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SettingsView: View {
|
|
private let contentTopInset: CGFloat = 8
|
|
private let pickerColumnWidth: CGFloat = 196
|
|
|
|
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
|
|
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
|
|
@AppStorage("claudeCodeHooksEnabled") private var claudeCodeHooksEnabled = true
|
|
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
|
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
|
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
|
@AppStorage(UpdateChannelSettings.includeNightlyBuildsKey) private var includeNightlyBuilds = UpdateChannelSettings.defaultIncludeNightlyBuilds
|
|
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
|
@State private var shortcutResetToken = UUID()
|
|
@State private var topBlurOpacity: Double = 0
|
|
@State private var topBlurBaselineOffset: CGFloat?
|
|
@State private var settingsTitleLeadingInset: CGFloat = 92
|
|
@State private var showClearBrowserHistoryConfirmation = false
|
|
@State private var browserHistoryEntryCount: Int = 0
|
|
|
|
private var selectedWorkspacePlacement: NewWorkspacePlacement {
|
|
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
|
|
}
|
|
|
|
private var selectedSocketControlMode: SocketControlMode {
|
|
SocketControlMode(rawValue: socketControlMode) ?? SocketControlSettings.defaultMode
|
|
}
|
|
|
|
private var browserHistorySubtitle: String {
|
|
switch browserHistoryEntryCount {
|
|
case 0:
|
|
return "No saved pages yet."
|
|
case 1:
|
|
return "1 saved page appears in omnibar suggestions."
|
|
default:
|
|
return "\(browserHistoryEntryCount) saved pages appear in omnibar suggestions."
|
|
}
|
|
}
|
|
|
|
private func blurOpacity(forContentOffset offset: CGFloat) -> Double {
|
|
guard let baseline = topBlurBaselineOffset else { return 0 }
|
|
let reveal = (baseline - offset) / 24
|
|
return Double(min(max(reveal, 0), 1))
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .top) {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
SettingsSectionHeader(title: "App")
|
|
SettingsCard {
|
|
SettingsCardRow("Theme", controlWidth: pickerColumnWidth) {
|
|
Picker("", selection: $appearanceMode) {
|
|
ForEach(AppearanceMode.visibleCases) { mode in
|
|
Text(mode.displayName).tag(mode.rawValue)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
"New Workspace Placement",
|
|
subtitle: selectedWorkspacePlacement.description,
|
|
controlWidth: pickerColumnWidth
|
|
) {
|
|
Picker("", selection: $newWorkspacePlacement) {
|
|
ForEach(NewWorkspacePlacement.allCases) { placement in
|
|
Text(placement.displayName).tag(placement.rawValue)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
"Dock Badge",
|
|
subtitle: "Show unread count on app icon (Dock and Cmd+Tab)."
|
|
) {
|
|
Toggle("", isOn: $notificationDockBadgeEnabled)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
|
|
SettingsSectionHeader(title: "Updates")
|
|
SettingsCard {
|
|
SettingsCardRow(
|
|
"Receive Nightly Builds",
|
|
subtitle: includeNightlyBuilds
|
|
? "Using nightly update channel. Builds may be less stable."
|
|
: "Using stable update channel."
|
|
) {
|
|
Toggle("", isOn: $includeNightlyBuilds)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
.accessibilityIdentifier("SettingsIncludeNightlyBuildsToggle")
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardNote("Nightly builds are published from the latest main branch commit when available.")
|
|
}
|
|
|
|
SettingsSectionHeader(title: "Automation")
|
|
SettingsCard {
|
|
SettingsCardRow(
|
|
"Socket Control Mode",
|
|
subtitle: selectedSocketControlMode.description,
|
|
controlWidth: pickerColumnWidth
|
|
) {
|
|
Picker("", selection: $socketControlMode) {
|
|
ForEach(SocketControlMode.allCases) { mode in
|
|
Text(mode.displayName).tag(mode.rawValue)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
.accessibilityIdentifier("AutomationSocketModePicker")
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardNote("Expose a local Unix socket for programmatic control. This can be a security risk on shared machines.")
|
|
SettingsCardNote("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH.")
|
|
}
|
|
|
|
SettingsCard {
|
|
SettingsCardRow(
|
|
"Claude Code Integration",
|
|
subtitle: claudeCodeHooksEnabled
|
|
? "Sidebar shows Claude session status and notifications."
|
|
: "Claude Code runs without cmux integration."
|
|
) {
|
|
Toggle("", isOn: $claudeCodeHooksEnabled)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
.accessibilityIdentifier("SettingsClaudeCodeHooksToggle")
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardNote("When enabled, cmux wraps the claude command to inject session tracking and notification hooks. Disable if you prefer to manage Claude Code hooks yourself.")
|
|
}
|
|
|
|
SettingsSectionHeader(title: "Browser")
|
|
SettingsCard {
|
|
SettingsCardRow(
|
|
"Default Search Engine",
|
|
subtitle: "Used by the browser address bar when input is not a URL.",
|
|
controlWidth: pickerColumnWidth
|
|
) {
|
|
Picker("", selection: $browserSearchEngine) {
|
|
ForEach(BrowserSearchEngine.allCases) { engine in
|
|
Text(engine.displayName).tag(engine.rawValue)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow("Show Search Suggestions") {
|
|
Toggle("", isOn: $browserSearchSuggestionsEnabled)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow("Browsing History", subtitle: browserHistorySubtitle) {
|
|
Button("Clear History…") {
|
|
showClearBrowserHistoryConfirmation = true
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.disabled(browserHistoryEntryCount == 0)
|
|
}
|
|
}
|
|
|
|
SettingsSectionHeader(title: "Keyboard Shortcuts")
|
|
SettingsCard {
|
|
let actions = KeyboardShortcutSettings.Action.allCases
|
|
ForEach(Array(actions.enumerated()), id: \.element.id) { index, action in
|
|
ShortcutSettingRow(action: action)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 9)
|
|
if index < actions.count - 1 {
|
|
SettingsCardDivider()
|
|
}
|
|
}
|
|
}
|
|
.id(shortcutResetToken)
|
|
|
|
Text("Click a shortcut value to record a new shortcut.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.padding(.leading, 2)
|
|
|
|
SettingsSectionHeader(title: "Reset")
|
|
SettingsCard {
|
|
HStack {
|
|
Spacer(minLength: 0)
|
|
Button("Reset All Settings") {
|
|
resetAllSettings()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.regular)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
}
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.bottom, 20)
|
|
.padding(.top, contentTopInset)
|
|
.background(
|
|
GeometryReader { proxy in
|
|
Color.clear.preference(
|
|
key: SettingsTopOffsetPreferenceKey.self,
|
|
value: proxy.frame(in: .named("SettingsScrollArea")).minY
|
|
)
|
|
}
|
|
)
|
|
}
|
|
.coordinateSpace(name: "SettingsScrollArea")
|
|
.onPreferenceChange(SettingsTopOffsetPreferenceKey.self) { value in
|
|
if topBlurBaselineOffset == nil {
|
|
topBlurBaselineOffset = value
|
|
}
|
|
topBlurOpacity = blurOpacity(forContentOffset: value)
|
|
}
|
|
|
|
ZStack(alignment: .top) {
|
|
SettingsTitleLeadingInsetReader(inset: $settingsTitleLeadingInset)
|
|
.frame(width: 0, height: 0)
|
|
|
|
AboutVisualEffectBackground(material: .underWindowBackground, blendingMode: .withinWindow)
|
|
.mask(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.black.opacity(0.9),
|
|
Color.black.opacity(0.64),
|
|
Color.black.opacity(0.36),
|
|
Color.clear
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.opacity(0.52)
|
|
|
|
AboutVisualEffectBackground(material: .underWindowBackground, blendingMode: .withinWindow)
|
|
.mask(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.black.opacity(0.98),
|
|
Color.black.opacity(0.78),
|
|
Color.black.opacity(0.42),
|
|
Color.clear
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
)
|
|
.opacity(0.14 + (topBlurOpacity * 0.86))
|
|
|
|
HStack {
|
|
Text("Settings")
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundColor(.primary.opacity(0.92))
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.leading, settingsTitleLeadingInset)
|
|
.padding(.top, 12)
|
|
}
|
|
.frame(height: 62)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
.ignoresSafeArea(.container, edges: .top)
|
|
.overlay(
|
|
Rectangle()
|
|
.fill(Color(nsColor: .separatorColor).opacity(0.07))
|
|
.frame(height: 1),
|
|
alignment: .bottom
|
|
)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.background(Color(nsColor: .windowBackgroundColor).ignoresSafeArea())
|
|
.toggleStyle(.switch)
|
|
.onAppear {
|
|
BrowserHistoryStore.shared.loadIfNeeded()
|
|
browserHistoryEntryCount = BrowserHistoryStore.shared.entries.count
|
|
}
|
|
.onReceive(BrowserHistoryStore.shared.$entries) { entries in
|
|
browserHistoryEntryCount = entries.count
|
|
}
|
|
.confirmationDialog(
|
|
"Clear browser history?",
|
|
isPresented: $showClearBrowserHistoryConfirmation,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Clear History", role: .destructive) {
|
|
BrowserHistoryStore.shared.clearHistory()
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This removes visited-page suggestions from the browser omnibar.")
|
|
}
|
|
}
|
|
|
|
private func resetAllSettings() {
|
|
appearanceMode = AppearanceMode.dark.rawValue
|
|
socketControlMode = SocketControlSettings.defaultMode.rawValue
|
|
claudeCodeHooksEnabled = true
|
|
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
|
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
|
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
|
includeNightlyBuilds = UpdateChannelSettings.defaultIncludeNightlyBuilds
|
|
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
|
KeyboardShortcutSettings.resetAll()
|
|
shortcutResetToken = UUID()
|
|
}
|
|
}
|
|
|
|
private struct SettingsTopOffsetPreferenceKey: PreferenceKey {
|
|
static var defaultValue: CGFloat = 0
|
|
|
|
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
|
|
value = nextValue()
|
|
}
|
|
}
|
|
|
|
private struct SettingsTitleLeadingInsetReader: NSViewRepresentable {
|
|
@Binding var inset: CGFloat
|
|
|
|
func makeNSView(context: Context) -> NSView {
|
|
let view = NSView(frame: .zero)
|
|
return view
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSView, context: Context) {
|
|
DispatchQueue.main.async {
|
|
guard let window = nsView.window else { return }
|
|
let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton]
|
|
let maxX = buttons
|
|
.compactMap { window.standardWindowButton($0)?.frame.maxX }
|
|
.max() ?? 78
|
|
let nextInset = maxX + 14
|
|
if abs(nextInset - inset) > 0.5 {
|
|
inset = nextInset
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SettingsSectionHeader: View {
|
|
let title: String
|
|
|
|
var body: some View {
|
|
Text(title)
|
|
.font(.system(size: 13, weight: .semibold))
|
|
.foregroundColor(.secondary)
|
|
.padding(.leading, 2)
|
|
.padding(.bottom, -2)
|
|
}
|
|
}
|
|
|
|
private struct SettingsCard<Content: View>: View {
|
|
@ViewBuilder let content: Content
|
|
|
|
init(@ViewBuilder content: () -> Content) {
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
content
|
|
}
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
|
.fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.76))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 13, style: .continuous)
|
|
.stroke(Color(nsColor: NSColor.separatorColor).opacity(0.5), lineWidth: 1)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct SettingsCardRow<Trailing: View>: View {
|
|
let title: String
|
|
let subtitle: String?
|
|
let controlWidth: CGFloat?
|
|
@ViewBuilder let trailing: Trailing
|
|
|
|
init(
|
|
_ title: String,
|
|
subtitle: String? = nil,
|
|
controlWidth: CGFloat? = nil,
|
|
@ViewBuilder trailing: () -> Trailing
|
|
) {
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.controlWidth = controlWidth
|
|
self.trailing = trailing()
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: subtitle == nil ? 0 : 3) {
|
|
Text(title)
|
|
.font(.system(size: 13, weight: .medium))
|
|
if let subtitle {
|
|
Text(subtitle)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Group {
|
|
if let controlWidth {
|
|
trailing
|
|
.frame(width: controlWidth, alignment: .trailing)
|
|
} else {
|
|
trailing
|
|
}
|
|
}
|
|
.layoutPriority(1)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 9)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private struct SettingsCardDivider: View {
|
|
var body: some View {
|
|
Rectangle()
|
|
.fill(Color(nsColor: NSColor.separatorColor).opacity(0.5))
|
|
.frame(height: 1)
|
|
}
|
|
}
|
|
|
|
private struct SettingsCardNote: View {
|
|
let text: String
|
|
|
|
init(_ text: String) {
|
|
self.text = text
|
|
}
|
|
|
|
var body: some View {
|
|
Text(text)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private struct ShortcutSettingRow: View {
|
|
let action: KeyboardShortcutSettings.Action
|
|
@State private var shortcut: StoredShortcut
|
|
|
|
init(action: KeyboardShortcutSettings.Action) {
|
|
self.action = action
|
|
_shortcut = State(initialValue: KeyboardShortcutSettings.shortcut(for: action))
|
|
}
|
|
|
|
var body: some View {
|
|
KeyboardShortcutRecorder(label: action.label, shortcut: $shortcut)
|
|
.onChange(of: shortcut) { newValue in
|
|
KeyboardShortcutSettings.setShortcut(newValue, for: action)
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in
|
|
let latest = KeyboardShortcutSettings.shortcut(for: action)
|
|
if latest != shortcut {
|
|
shortcut = latest
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct SettingsRootView: View {
|
|
var body: some View {
|
|
SettingsView()
|
|
.background(WindowAccessor { window in
|
|
configureSettingsWindow(window)
|
|
})
|
|
}
|
|
|
|
private func configureSettingsWindow(_ window: NSWindow) {
|
|
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
|
|
applyCurrentSettingsWindowStyle(to: window)
|
|
|
|
let accessories = window.titlebarAccessoryViewControllers
|
|
for index in accessories.indices.reversed() {
|
|
guard let identifier = accessories[index].view.identifier?.rawValue else { continue }
|
|
guard identifier.hasPrefix("cmux.") else { continue }
|
|
window.removeTitlebarAccessoryViewController(at: index)
|
|
}
|
|
AppDelegate.shared?.applyWindowDecorations(to: window)
|
|
}
|
|
|
|
private func applyCurrentSettingsWindowStyle(to window: NSWindow) {
|
|
SettingsAboutTitlebarDebugStore.shared.applyCurrentOptions(to: window, for: .settings)
|
|
}
|
|
}
|