import SwiftUI import AppKit /// Tabbed settings panel with General, Appearance, Animation, Terminal, Hotkeys, and About. struct SettingsView: View { @State private var selectedTab: SettingsTab = .general var body: some View { NavigationSplitView { List(SettingsTab.allCases, selection: $selectedTab) { tab in Label(tab.label, systemImage: tab.icon) .tag(tab) } .listStyle(.sidebar) .navigationSplitViewColumnWidth(min: 180, ideal: 200) } detail: { ScrollView { detailView.padding() } .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame(minWidth: 600, minHeight: 400) } @ViewBuilder private var detailView: some View { switch selectedTab { case .general: GeneralSettingsView() case .appearance: AppearanceSettingsView() case .animation: AnimationSettingsView() case .terminal: TerminalSettingsView() case .hotkeys: HotkeySettingsView() case .about: AboutSettingsView() } } } // MARK: - Tabs enum SettingsTab: String, CaseIterable, Identifiable { case general, appearance, animation, terminal, hotkeys, about var id: String { rawValue } var label: String { switch self { case .general: return "General" case .appearance: return "Appearance" case .animation: return "Animation" case .terminal: return "Terminal" case .hotkeys: return "Hotkeys" case .about: return "About" } } var icon: String { switch self { case .general: return "gearshape" case .appearance: return "paintbrush" case .animation: return "bolt.fill" case .terminal: return "terminal" case .hotkeys: return "keyboard" case .about: return "info.circle" } } } // MARK: - General struct GeneralSettingsView: View { @AppStorage(NotchSettings.Keys.showOnAllDisplays) private var showOnAllDisplays = NotchSettings.Defaults.showOnAllDisplays @AppStorage(NotchSettings.Keys.openNotchOnHover) private var openNotchOnHover = NotchSettings.Defaults.openNotchOnHover @AppStorage(NotchSettings.Keys.minimumHoverDuration) private var minimumHoverDuration = NotchSettings.Defaults.minimumHoverDuration @AppStorage(NotchSettings.Keys.showMenuBarIcon) private var showMenuBarIcon = NotchSettings.Defaults.showMenuBarIcon @AppStorage(NotchSettings.Keys.launchAtLogin) private var launchAtLogin = NotchSettings.Defaults.launchAtLogin @AppStorage(NotchSettings.Keys.enableGestures) private var enableGestures = NotchSettings.Defaults.enableGestures @AppStorage(NotchSettings.Keys.gestureSensitivity) private var gestureSensitivity = NotchSettings.Defaults.gestureSensitivity @AppStorage(NotchSettings.Keys.notchHeightMode) private var notchHeightMode = NotchSettings.Defaults.notchHeightMode @AppStorage(NotchSettings.Keys.notchHeight) private var notchHeight = NotchSettings.Defaults.notchHeight @AppStorage(NotchSettings.Keys.nonNotchHeightMode) private var nonNotchHeightMode = NotchSettings.Defaults.nonNotchHeightMode @AppStorage(NotchSettings.Keys.nonNotchHeight) private var nonNotchHeight = NotchSettings.Defaults.nonNotchHeight @AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth @AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight private var maxOpenWidth: Double { max(openWidth, Double((NSScreen.screens.map { $0.frame.width - 40 }.max() ?? 1600).rounded())) } private var maxOpenHeight: Double { max(openHeight, Double((NSScreen.screens.map { $0.frame.height - 20 }.max() ?? 900).rounded())) } var body: some View { Form { Section("Display") { Toggle("Show on all displays", isOn: $showOnAllDisplays) Toggle("Show menu bar icon", isOn: $showMenuBarIcon) Toggle("Launch at login", isOn: $launchAtLogin) .onChange(of: launchAtLogin) { _, newValue in LaunchAtLoginHelper.setEnabled(newValue) } } Section("Hover Behavior") { Toggle("Open notch on hover", isOn: $openNotchOnHover) if openNotchOnHover { HStack { Text("Hover delay") Slider(value: $minimumHoverDuration, in: 0.0...2.0, step: 0.05) Text(String(format: "%.2fs", minimumHoverDuration)) .monospacedDigit().frame(width: 50) } } } Section("Gestures") { Toggle("Enable gestures", isOn: $enableGestures) if enableGestures { HStack { Text("Sensitivity") Slider(value: $gestureSensitivity, in: 0.1...1.0, step: 0.05) Text(String(format: "%.2f", gestureSensitivity)) .monospacedDigit().frame(width: 50) } } } Section("Closed Notch Size") { Picker("Notch screens", selection: $notchHeightMode) { ForEach(NotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) } } if notchHeightMode == NotchHeightMode.custom.rawValue { HStack { Text("Custom height") Slider(value: $notchHeight, in: 16...64, step: 1) Text("\(Int(notchHeight))pt").monospacedDigit().frame(width: 50) } } Picker("Non-notch screens", selection: $nonNotchHeightMode) { ForEach(NonNotchHeightMode.allCases) { Text($0.label).tag($0.rawValue) } } if nonNotchHeightMode == NonNotchHeightMode.custom.rawValue { HStack { Text("Custom height") Slider(value: $nonNotchHeight, in: 16...64, step: 1) Text("\(Int(nonNotchHeight))pt").monospacedDigit().frame(width: 50) } } } Section("Open Notch Size") { HStack { Text("Width") Slider(value: $openWidth, in: 320...maxOpenWidth, step: 10) Text("\(Int(openWidth))pt").monospacedDigit().frame(width: 60) } HStack { Text("Height") Slider(value: $openHeight, in: 140...maxOpenHeight, step: 10) Text("\(Int(openHeight))pt").monospacedDigit().frame(width: 60) } } } .formStyle(.grouped) } } // MARK: - Appearance struct AppearanceSettingsView: View { @AppStorage(NotchSettings.Keys.enableShadow) private var enableShadow = NotchSettings.Defaults.enableShadow @AppStorage(NotchSettings.Keys.shadowRadius) private var shadowRadius = NotchSettings.Defaults.shadowRadius @AppStorage(NotchSettings.Keys.shadowOpacity) private var shadowOpacity = NotchSettings.Defaults.shadowOpacity @AppStorage(NotchSettings.Keys.cornerRadiusScaling) private var cornerRadiusScaling = NotchSettings.Defaults.cornerRadiusScaling @AppStorage(NotchSettings.Keys.notchOpacity) private var notchOpacity = NotchSettings.Defaults.notchOpacity @AppStorage(NotchSettings.Keys.blurRadius) private var blurRadius = NotchSettings.Defaults.blurRadius var body: some View { Form { Section("Shadow") { Toggle("Enable shadow", isOn: $enableShadow) if enableShadow { HStack { Text("Radius") Slider(value: $shadowRadius, in: 0...30, step: 1) Text(String(format: "%.0f", shadowRadius)).monospacedDigit().frame(width: 40) } HStack { Text("Opacity") Slider(value: $shadowOpacity, in: 0...1, step: 0.05) Text(String(format: "%.2f", shadowOpacity)).monospacedDigit().frame(width: 50) } } } Section("Shape") { Toggle("Scale corner radii when open", isOn: $cornerRadiusScaling) } Section("Opacity & Blur") { HStack { Text("Notch opacity") Slider(value: $notchOpacity, in: 0...1, step: 0.05) Text(String(format: "%.2f", notchOpacity)).monospacedDigit().frame(width: 50) } HStack { Text("Blur radius") Slider(value: $blurRadius, in: 0...20, step: 0.5) Text(String(format: "%.1f", blurRadius)).monospacedDigit().frame(width: 50) } } } .formStyle(.grouped) } } // MARK: - Animation struct AnimationSettingsView: View { @AppStorage(NotchSettings.Keys.openSpringResponse) private var openResponse = NotchSettings.Defaults.openSpringResponse @AppStorage(NotchSettings.Keys.openSpringDamping) private var openDamping = NotchSettings.Defaults.openSpringDamping @AppStorage(NotchSettings.Keys.closeSpringResponse) private var closeResponse = NotchSettings.Defaults.closeSpringResponse @AppStorage(NotchSettings.Keys.closeSpringDamping) private var closeDamping = NotchSettings.Defaults.closeSpringDamping @AppStorage(NotchSettings.Keys.hoverSpringResponse) private var hoverResponse = NotchSettings.Defaults.hoverSpringResponse @AppStorage(NotchSettings.Keys.hoverSpringDamping) private var hoverDamping = NotchSettings.Defaults.hoverSpringDamping var body: some View { Form { Section("Open Animation") { springControls(response: $openResponse, damping: $openDamping) } Section("Close Animation") { springControls(response: $closeResponse, damping: $closeDamping) } Section("Hover Animation") { springControls(response: $hoverResponse, damping: $hoverDamping) } Section { Button("Reset to Defaults") { openResponse = NotchSettings.Defaults.openSpringResponse openDamping = NotchSettings.Defaults.openSpringDamping closeResponse = NotchSettings.Defaults.closeSpringResponse closeDamping = NotchSettings.Defaults.closeSpringDamping hoverResponse = NotchSettings.Defaults.hoverSpringResponse hoverDamping = NotchSettings.Defaults.hoverSpringDamping } } } .formStyle(.grouped) } @ViewBuilder private func springControls(response: Binding, damping: Binding) -> some View { HStack { Text("Response") Slider(value: response, in: 0.1...1.5, step: 0.01) Text(String(format: "%.2f", response.wrappedValue)).monospacedDigit().frame(width: 50) } HStack { Text("Damping") Slider(value: damping, in: 0.1...1.5, step: 0.01) Text(String(format: "%.2f", damping.wrappedValue)).monospacedDigit().frame(width: 50) } } } // MARK: - Terminal struct TerminalSettingsView: View { @AppStorage(NotchSettings.Keys.terminalFontSize) private var fontSize = NotchSettings.Defaults.terminalFontSize @AppStorage(NotchSettings.Keys.terminalShell) private var shellPath = NotchSettings.Defaults.terminalShell @AppStorage(NotchSettings.Keys.terminalTheme) private var theme = NotchSettings.Defaults.terminalTheme @AppStorage(NotchSettings.Keys.openWidth) private var openWidth = NotchSettings.Defaults.openWidth @AppStorage(NotchSettings.Keys.openHeight) private var openHeight = NotchSettings.Defaults.openHeight @State private var sizePresets = TerminalSizePresetStore.load() var body: some View { Form { Section("Font") { HStack { Text("Font size") Slider(value: $fontSize, in: 8...28, step: 1) Text("\(Int(fontSize))pt").monospacedDigit().frame(width: 50) } } Section("Colors") { Picker("Theme", selection: $theme) { ForEach(TerminalTheme.allCases) { terminalTheme in Text(terminalTheme.label).tag(terminalTheme.rawValue) } } Text(TerminalTheme.resolve(theme).detail) .font(.caption) .foregroundStyle(.secondary) Text("Applies to normal terminal text and the ANSI palette used by tools like `ls`.") .font(.caption) .foregroundStyle(.secondary) } Section("Shell") { TextField("Shell path (empty = $SHELL)", text: $shellPath) .textFieldStyle(.roundedBorder) Text("Leave empty to use your default shell ($SHELL or /bin/zsh).") .font(.caption) .foregroundStyle(.secondary) } Section("Size Presets") { ForEach($sizePresets) { $preset in TerminalSizePresetEditor( preset: $preset, currentOpenWidth: openWidth, currentOpenHeight: openHeight, onDelete: { deletePreset(id: preset.id) }, onApply: { applyPreset(preset) } ) } HStack { Button("Add Preset") { sizePresets.append( TerminalSizePreset( name: "Preset \(sizePresets.count + 1)", width: openWidth, height: openHeight, hotkey: TerminalSizePresetStore.suggestedHotkey(for: sizePresets) ) ) } Button("Reset Presets") { sizePresets = TerminalSizePresetStore.loadDefaults() } } Text("Size preset hotkeys are active when the notch is open. Default presets use ⌘⇧1, ⌘⇧2, and ⌘⇧3.") .font(.caption) .foregroundStyle(.secondary) } } .formStyle(.grouped) .onChange(of: sizePresets) { _, newValue in TerminalSizePresetStore.save(newValue) } } private func deletePreset(id: UUID) { sizePresets.removeAll { $0.id == id } } private func applyPreset(_ preset: TerminalSizePreset) { openWidth = preset.width openHeight = preset.height ScreenManager.shared.applySizePreset(preset) } } // MARK: - Hotkeys struct HotkeySettingsView: View { @State private var toggleBinding = loadBinding(NotchSettings.Keys.hotkeyToggle, fallback: .cmdReturn) @State private var newTabBinding = loadBinding(NotchSettings.Keys.hotkeyNewTab, fallback: .cmdT) @State private var closeTabBinding = loadBinding(NotchSettings.Keys.hotkeyCloseTab, fallback: .cmdW) @State private var nextTabBinding = loadBinding(NotchSettings.Keys.hotkeyNextTab, fallback: .cmdShiftRB) @State private var prevTabBinding = loadBinding(NotchSettings.Keys.hotkeyPreviousTab, fallback: .cmdShiftLB) @State private var detachBinding = loadBinding(NotchSettings.Keys.hotkeyDetachTab, fallback: .cmdD) var body: some View { Form { Section("Global") { HotkeyRecorderView(label: "Toggle notch", binding: bindAndSave($toggleBinding, key: NotchSettings.Keys.hotkeyToggle)) } Section("Terminal Tabs (active when notch is open)") { HotkeyRecorderView(label: "New tab", binding: bindAndSave($newTabBinding, key: NotchSettings.Keys.hotkeyNewTab)) HotkeyRecorderView(label: "Close tab", binding: bindAndSave($closeTabBinding, key: NotchSettings.Keys.hotkeyCloseTab)) HotkeyRecorderView(label: "Next tab", binding: bindAndSave($nextTabBinding, key: NotchSettings.Keys.hotkeyNextTab)) HotkeyRecorderView(label: "Previous tab", binding: bindAndSave($prevTabBinding, key: NotchSettings.Keys.hotkeyPreviousTab)) HotkeyRecorderView(label: "Detach tab", binding: bindAndSave($detachBinding, key: NotchSettings.Keys.hotkeyDetachTab)) } Section { Text("⌘1–9 always switch to tab by number. Size preset hotkeys are configured in Terminal > Size Presets.") .font(.caption) .foregroundStyle(.secondary) } Section { Button("Reset to Defaults") { toggleBinding = .cmdReturn newTabBinding = .cmdT closeTabBinding = .cmdW nextTabBinding = .cmdShiftRB prevTabBinding = .cmdShiftLB detachBinding = .cmdD save(.cmdReturn, key: NotchSettings.Keys.hotkeyToggle) save(.cmdT, key: NotchSettings.Keys.hotkeyNewTab) save(.cmdW, key: NotchSettings.Keys.hotkeyCloseTab) save(.cmdShiftRB, key: NotchSettings.Keys.hotkeyNextTab) save(.cmdShiftLB, key: NotchSettings.Keys.hotkeyPreviousTab) save(.cmdD, key: NotchSettings.Keys.hotkeyDetachTab) } } } .formStyle(.grouped) } /// Creates a binding that saves to UserDefaults on every change. private func bindAndSave(_ state: Binding, key: String) -> Binding { Binding( get: { state.wrappedValue }, set: { newValue in state.wrappedValue = newValue save(newValue, key: key) } ) } private func save(_ binding: HotkeyBinding, key: String) { UserDefaults.standard.set(binding.toJSON(), forKey: key) } private static func loadBinding(_ key: String, fallback: HotkeyBinding) -> HotkeyBinding { guard let json = UserDefaults.standard.string(forKey: key), let b = HotkeyBinding.fromJSON(json) else { return fallback } return b } } private struct TerminalSizePresetEditor: View { @Binding var preset: TerminalSizePreset let currentOpenWidth: Double let currentOpenHeight: Double let onDelete: () -> Void let onApply: () -> Void var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { TextField("Preset name", text: $preset.name) .textFieldStyle(.roundedBorder) Button(role: .destructive, action: onDelete) { Image(systemName: "trash") } .buttonStyle(.borderless) } HStack { Text("Width") TextField("Width", value: $preset.width, format: .number.precision(.fractionLength(0))) .textFieldStyle(.roundedBorder) .frame(width: 90) Text("Height") TextField("Height", value: $preset.height, format: .number.precision(.fractionLength(0))) .textFieldStyle(.roundedBorder) .frame(width: 90) Spacer() Button("Use Current Size") { preset.width = currentOpenWidth preset.height = currentOpenHeight } Button("Apply", action: onApply) } OptionalHotkeyRecorderView(label: "Hotkey", binding: $preset.hotkey) } .padding(.vertical, 4) } } // MARK: - About struct AboutSettingsView: View { var body: some View { VStack(spacing: 16) { Image(systemName: "terminal") .font(.system(size: 64)) .foregroundStyle(.secondary) Text("CommandNotch") .font(.largeTitle.bold()) Text("Version 0.3.0") .foregroundStyle(.secondary) Text("A drop-down terminal that lives in your notch.") .multilineTextAlignment(.center) .foregroundStyle(.secondary) Spacer() } .frame(maxWidth: .infinity) .padding(.top, 40) } }