* chore(claude-opus-4-6): From HN feedback: https://news.ycombinator.com/item?id=47... * Centralize workspace auto-reorder into addNotification Move moveTabToTop into TerminalNotificationStore.addNotification so all notification paths (Ghostty actions, v2 API, control socket) respect the reorder-on-notification setting, not just the two Ghostty action sites. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2920 lines
119 KiB
Swift
2920 lines
119 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(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.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()
|
|
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data()
|
|
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data()
|
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
|
|
|
init() {
|
|
Self.configureGhosttyEnvironment()
|
|
|
|
let startupAppearance = AppearanceSettings.resolvedMode()
|
|
Self.applyAppearance(startupAppearance)
|
|
_tabManager = StateObject(wrappedValue: TabManager())
|
|
// Migrate legacy and old-format socket mode values to the new enum.
|
|
let defaults = UserDefaults.standard
|
|
if let stored = defaults.string(forKey: SocketControlSettings.appStorageKey) {
|
|
let migrated = SocketControlSettings.migrateMode(stored)
|
|
if migrated.rawValue != stored {
|
|
defaults.set(migrated.rawValue, forKey: SocketControlSettings.appStorageKey)
|
|
}
|
|
} else if let legacy = defaults.object(forKey: SocketControlSettings.legacyEnabledKey) as? Bool {
|
|
defaults.set(legacy ? SocketControlMode.cmuxOnly.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 static 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 static 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)
|
|
}
|
|
|
|
splitCommandButton(title: "Split Browser Right", shortcut: splitBrowserRightMenuShortcut) {
|
|
performBrowserSplitFromMenu(direction: .right)
|
|
}
|
|
|
|
splitCommandButton(title: "Split Browser Down", shortcut: splitBrowserDownMenuShortcut) {
|
|
performBrowserSplitFromMenu(direction: .down)
|
|
}
|
|
|
|
Divider()
|
|
|
|
// Cmd+1 through Cmd+9 for workspace selection (9 = last workspace)
|
|
ForEach(1...9, id: \.self) { number in
|
|
Button("Workspace \(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() {
|
|
let mode = AppearanceSettings.mode(for: appearanceMode)
|
|
if appearanceMode != mode.rawValue {
|
|
appearanceMode = mode.rawValue
|
|
}
|
|
Self.applyAppearance(mode)
|
|
}
|
|
|
|
private static func applyAppearance(_ mode: AppearanceMode) {
|
|
switch mode {
|
|
case .system:
|
|
NSApplication.shared.appearance = nil
|
|
case .light:
|
|
NSApplication.shared.appearance = NSAppearance(named: .aqua)
|
|
case .dark:
|
|
NSApplication.shared.appearance = NSAppearance(named: .darkAqua)
|
|
case .auto:
|
|
NSApplication.shared.appearance = nil
|
|
}
|
|
}
|
|
|
|
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 {
|
|
SocketControlSettings.migrateMode(socketControlMode)
|
|
}
|
|
|
|
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 splitBrowserRightMenuShortcut: StoredShortcut {
|
|
decodeShortcut(
|
|
from: splitBrowserRightShortcutData,
|
|
fallback: KeyboardShortcutSettings.Action.splitBrowserRight.defaultShortcut
|
|
)
|
|
}
|
|
|
|
private var splitBrowserDownMenuShortcut: StoredShortcut {
|
|
decodeShortcut(
|
|
from: splitBrowserDownShortcutData,
|
|
fallback: KeyboardShortcutSettings.Action.splitBrowserDown.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)
|
|
}
|
|
|
|
private func performBrowserSplitFromMenu(direction: SplitDirection) {
|
|
if AppDelegate.shared?.performBrowserSplitShortcut(direction: direction) == true {
|
|
return
|
|
}
|
|
_ = tabManager.createBrowserSplit(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
|
|
@AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0
|
|
|
|
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("Titlebar Spacing") {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(spacing: 8) {
|
|
Text("Leading extra")
|
|
Slider(value: $titlebarLeadingExtra, in: 0...40)
|
|
Text(String(format: "%.0f", titlebarLeadingExtra))
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.frame(width: 30, alignment: .trailing)
|
|
}
|
|
Button("Reset (0)") {
|
|
titlebarLeadingExtra = 0
|
|
}
|
|
}
|
|
.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"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AppearanceSettings {
|
|
static let appearanceModeKey = "appearanceMode"
|
|
static let defaultMode: AppearanceMode = .system
|
|
|
|
static func mode(for rawValue: String?) -> AppearanceMode {
|
|
guard let rawValue, let mode = AppearanceMode(rawValue: rawValue) else {
|
|
return defaultMode
|
|
}
|
|
if mode == .auto {
|
|
return .system
|
|
}
|
|
return mode
|
|
}
|
|
|
|
@discardableResult
|
|
static func resolvedMode(defaults: UserDefaults = .standard) -> AppearanceMode {
|
|
let stored = defaults.string(forKey: appearanceModeKey)
|
|
let resolved = mode(for: stored)
|
|
if stored != resolved.rawValue {
|
|
defaults.set(resolved.rawValue, forKey: appearanceModeKey)
|
|
}
|
|
return resolved
|
|
}
|
|
}
|
|
|
|
struct SettingsView: View {
|
|
private let contentTopInset: CGFloat = 8
|
|
private let pickerColumnWidth: CGFloat = 196
|
|
|
|
@AppStorage(AppearanceSettings.appearanceModeKey) private var appearanceMode = AppearanceSettings.defaultMode.rawValue
|
|
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
|
|
@AppStorage("claudeCodeHooksEnabled") private var claudeCodeHooksEnabled = true
|
|
@AppStorage("cmuxPortBase") private var cmuxPortBase = 9100
|
|
@AppStorage("cmuxPortRange") private var cmuxPortRange = 10
|
|
@AppStorage(BrowserSearchSettings.searchEngineKey) private var browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
|
@AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
|
@AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
|
|
@AppStorage(BrowserInsecureHTTPSettings.allowlistKey) private var browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
|
|
@AppStorage(NotificationBadgeSettings.dockBadgeEnabledKey) private var notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
|
@AppStorage(WorkspacePlacementSettings.placementKey) private var newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
|
@AppStorage(WorkspaceAutoReorderSettings.key) private var workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
|
@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
|
|
@State private var browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
|
|
|
private var selectedWorkspacePlacement: NewWorkspacePlacement {
|
|
NewWorkspacePlacement(rawValue: newWorkspacePlacement) ?? WorkspacePlacementSettings.defaultPlacement
|
|
}
|
|
|
|
private var selectedSocketControlMode: SocketControlMode {
|
|
SocketControlSettings.migrateMode(socketControlMode)
|
|
}
|
|
|
|
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 var browserInsecureHTTPAllowlistHasUnsavedChanges: Bool {
|
|
browserInsecureHTTPAllowlistDraft != browserInsecureHTTPAllowlist
|
|
}
|
|
|
|
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(
|
|
"Reorder on Notification",
|
|
subtitle: "Move workspaces to the top when they receive a notification. Disable for stable shortcut positions."
|
|
) {
|
|
Toggle("", isOn: $workspaceAutoReorder)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow(
|
|
"Dock Badge",
|
|
subtitle: "Show unread count on app icon (Dock and Cmd+Tab)."
|
|
) {
|
|
Toggle("", isOn: $notificationDockBadgeEnabled)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
|
|
SettingsSectionHeader(title: "Automation")
|
|
SettingsCard {
|
|
SettingsCardRow(
|
|
"Socket Control Mode",
|
|
subtitle: selectedSocketControlMode.description,
|
|
controlWidth: pickerColumnWidth
|
|
) {
|
|
Picker("", selection: $socketControlMode) {
|
|
ForEach(SocketControlMode.uiCases) { mode in
|
|
Text(mode.displayName).tag(mode.rawValue)
|
|
}
|
|
}
|
|
.labelsHidden()
|
|
.pickerStyle(.menu)
|
|
.accessibilityIdentifier("AutomationSocketModePicker")
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardNote("Controls access to the local Unix socket for programmatic control. In \"cmux processes only\" mode, only processes spawned inside cmux terminals can connect.")
|
|
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.")
|
|
}
|
|
|
|
SettingsCard {
|
|
SettingsCardRow("Port Base", subtitle: "Starting port for CMUX_PORT env var.", controlWidth: pickerColumnWidth) {
|
|
TextField("", value: $cmuxPortBase, format: .number)
|
|
.textFieldStyle(.roundedBorder)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardRow("Port Range Size", subtitle: "Number of ports per workspace.", controlWidth: pickerColumnWidth) {
|
|
TextField("", value: $cmuxPortRange, format: .number)
|
|
.textFieldStyle(.roundedBorder)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
SettingsCardNote("Each workspace gets CMUX_PORT and CMUX_PORT_END env vars with a dedicated port range. New terminals inherit these values.")
|
|
}
|
|
|
|
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(
|
|
"Open Terminal Links in cmux Browser",
|
|
subtitle: "When off, links clicked in terminal output open in your default browser."
|
|
) {
|
|
Toggle("", isOn: $openTerminalLinksInCmuxBrowser)
|
|
.labelsHidden()
|
|
.controlSize(.small)
|
|
}
|
|
|
|
SettingsCardDivider()
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("HTTP Host Allowlist")
|
|
.font(.system(size: 13, weight: .semibold))
|
|
|
|
Text("HTTP loads outside this list show a warning prompt with options to open externally or proceed.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
TextEditor(text: $browserInsecureHTTPAllowlistDraft)
|
|
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
|
.frame(minHeight: 86)
|
|
.padding(6)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.fill(Color(nsColor: .textBackgroundColor))
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
|
.stroke(Color(nsColor: .separatorColor), lineWidth: 1)
|
|
)
|
|
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistField")
|
|
|
|
ViewThatFits(in: .horizontal) {
|
|
HStack(alignment: .center, spacing: 10) {
|
|
Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
Spacer(minLength: 0)
|
|
|
|
Button("Save") {
|
|
saveBrowserInsecureHTTPAllowlist()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
|
|
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("One host or wildcard per line (for example: localhost, 127.0.0.1, *.localtest.me).")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack {
|
|
Spacer(minLength: 0)
|
|
Button("Save") {
|
|
saveBrowserInsecureHTTPAllowlist()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.controlSize(.small)
|
|
.disabled(!browserInsecureHTTPAllowlistHasUnsavedChanges)
|
|
.accessibilityIdentifier("SettingsBrowserHTTPAllowlistSaveButton")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
|
|
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
|
|
browserInsecureHTTPAllowlistDraft = browserInsecureHTTPAllowlist
|
|
}
|
|
.onChange(of: browserInsecureHTTPAllowlist) { oldValue, newValue in
|
|
// Keep draft in sync with external changes unless the user has local unsaved edits.
|
|
if browserInsecureHTTPAllowlistDraft == oldValue {
|
|
browserInsecureHTTPAllowlistDraft = newValue
|
|
}
|
|
}
|
|
.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 = AppearanceSettings.defaultMode.rawValue
|
|
socketControlMode = SocketControlSettings.defaultMode.rawValue
|
|
claudeCodeHooksEnabled = true
|
|
browserSearchEngine = BrowserSearchSettings.defaultSearchEngine.rawValue
|
|
browserSearchSuggestionsEnabled = BrowserSearchSettings.defaultSearchSuggestionsEnabled
|
|
openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
|
|
browserInsecureHTTPAllowlist = BrowserInsecureHTTPSettings.defaultAllowlistText
|
|
browserInsecureHTTPAllowlistDraft = BrowserInsecureHTTPSettings.defaultAllowlistText
|
|
notificationDockBadgeEnabled = NotificationBadgeSettings.defaultDockBadgeEnabled
|
|
newWorkspacePlacement = WorkspacePlacementSettings.defaultPlacement.rawValue
|
|
workspaceAutoReorder = WorkspaceAutoReorderSettings.defaultValue
|
|
KeyboardShortcutSettings.resetAll()
|
|
shortcutResetToken = UUID()
|
|
}
|
|
|
|
private func saveBrowserInsecureHTTPAllowlist() {
|
|
browserInsecureHTTPAllowlist = browserInsecureHTTPAllowlistDraft
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|