Atoll/DynamicIsland/components/Settings/SettingsView.swift
2025-11-29 14:04:38 +05:30

3830 lines
165 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// SettingsView.swift
// DynamicIsland
//
// Created by Richard Kunkli on 07/08/2024.
//
import Defaults
import EventKit
import KeyboardShortcuts
import LaunchAtLogin
import LottieUI
import AVFoundation
import Sparkle
import SwiftUI
import SwiftUIIntrospect
import UniformTypeIdentifiers
private enum SettingsTab: String, CaseIterable, Identifiable {
case general
case appearance
case lockScreen
case media
case timer
case calendar
case hud
case battery
case stats
case clipboard
case screenAssistant
case colorPicker
case downloads
case shelf
case shortcuts
case extensions
case about
var id: String { rawValue }
var title: String {
switch self {
case .general: return "General"
case .appearance: return "Appearance"
case .lockScreen: return "Lock Screen"
case .media: return "Media"
case .timer: return "Timer"
case .calendar: return "Calendar"
case .hud: return "HUDs"
case .battery: return "Battery"
case .stats: return "Stats"
case .clipboard: return "Clipboard"
case .screenAssistant: return "Screen Assistant"
case .colorPicker: return "Color Picker"
case .downloads: return "Downloads"
case .shelf: return "Shelf"
case .shortcuts: return "Shortcuts"
case .extensions: return "Extensions"
case .about: return "About"
}
}
var systemImage: String {
switch self {
case .general: return "gear"
case .appearance: return "paintpalette"
case .lockScreen: return "lock.laptopcomputer"
case .media: return "play.laptopcomputer"
case .timer: return "timer"
case .calendar: return "calendar"
case .hud: return "dial.medium.fill"
case .battery: return "battery.100.bolt"
case .stats: return "chart.xyaxis.line"
case .clipboard: return "clipboard"
case .screenAssistant: return "brain.head.profile"
case .colorPicker: return "eyedropper"
case .downloads: return "square.and.arrow.down"
case .shelf: return "books.vertical"
case .shortcuts: return "keyboard"
case .extensions: return "puzzlepiece.extension"
case .about: return "info.circle"
}
}
var tint: Color {
switch self {
case .general: return Color.blue
case .appearance: return Color.purple
case .lockScreen: return Color.orange
case .media: return Color.green
case .timer: return Color.red
case .calendar: return Color.cyan
case .hud: return Color.indigo
case .battery: return Color.yellow
case .stats: return Color.teal
case .clipboard: return Color.mint
case .screenAssistant: return Color.pink
case .colorPicker: return Color.accentColor
case .downloads: return Color.gray
case .shelf: return Color.brown
case .shortcuts: return Color.orange
case .extensions: return Color.blue
case .about: return Color.secondary
}
}
}
private struct SettingsSearchEntry: Identifiable {
let tab: SettingsTab
let title: String
let keywords: [String]
var id: String { "\(tab.rawValue)-\(title)" }
}
struct SettingsView: View {
@StateObject var extensionManager = DynamicIslandExtensionManager()
@State private var selectedTab: SettingsTab = .general
@State private var searchText: String = ""
@Default(.enableMinimalisticUI) var enableMinimalisticUI
let updaterController: SPUStandardUpdaterController?
init(updaterController: SPUStandardUpdaterController? = nil) {
self.updaterController = updaterController
}
var body: some View {
NavigationSplitView {
List(filteredTabs, selection: selectionBinding) { tab in
NavigationLink(value: tab) {
HStack(spacing: 10) {
sidebarIcon(for: tab)
Text(tab.title)
.foregroundStyle(Color.primary)
}
.padding(.vertical, 4)
}
.tag(tab)
.listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 10))
}
.listStyle(SidebarListStyle())
.frame(minWidth: 205)
.toolbar(removing: .sidebarToggle)
.navigationSplitViewColumnWidth(min: 220, ideal: 240, max: 360)
.searchable(text: $searchText, placement: .sidebar, prompt: Text("Search Settings"))
.searchSuggestions {
ForEach(searchSuggestions) { suggestion in
Button {
selectedTab = suggestion.tab
} label: {
Label {
Text("\(suggestion.title) — \(suggestion.tab.title)")
} icon: {
Image(systemName: suggestion.tab.systemImage)
}
}
}
}
.environment(\.defaultMinListRowHeight, 44)
} detail: {
detailView(for: resolvedSelection)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.navigationSplitViewStyle(.balanced)
.toolbar(removing: .sidebarToggle)
.toolbar {
Button("") {} // Empty label, does nothing
.controlSize(.extraLarge)
.opacity(0) // Invisible, but reserves space for a consistent look between tabs
.disabled(true)
}
.environmentObject(extensionManager)
.formStyle(.grouped)
.frame(width: 700)
.onChange(of: searchText) { _, newValue in
let matches = tabsMatchingSearch(newValue)
guard let firstMatch = matches.first else { return }
if !matches.contains(resolvedSelection) {
selectedTab = firstMatch
}
}
.background {
Group {
if #available(macOS 26.0, *) {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.glassEffect(
.clear
.tint(Color.white.opacity(0.1))
.interactive(),
in: .rect(cornerRadius: 18)
)
} else {
ZStack {
Color(NSColor.windowBackgroundColor)
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.ultraThinMaterial)
}
}
}
.ignoresSafeArea()
}
}
private var resolvedSelection: SettingsTab {
availableTabs.contains(selectedTab) ? selectedTab : (availableTabs.first ?? .general)
}
private var filteredTabs: [SettingsTab] {
tabsMatchingSearch(searchText)
}
private var selectionBinding: Binding<SettingsTab> {
Binding(
get: { resolvedSelection },
set: { selectedTab = $0 }
)
}
@ViewBuilder
private func sidebarIcon(for tab: SettingsTab) -> some View {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(tab.tint)
.frame(width: 26, height: 26)
.overlay {
Image(systemName: tab.systemImage)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Color.white)
}
}
private var availableTabs: [SettingsTab] {
let ordered: [SettingsTab] = [
.general,
.appearance,
.lockScreen,
.media,
.timer,
.calendar,
.hud,
.battery,
.stats,
.clipboard,
.screenAssistant,
.colorPicker,
.downloads,
.shelf,
.shortcuts,
.extensions,
.about
]
return ordered.filter { isTabVisible($0) }
}
private func tabsMatchingSearch(_ query: String) -> [SettingsTab] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return availableTabs }
let entryMatches = searchEntries(matching: trimmed)
let matchingTabs = Set(entryMatches.map(\.tab))
return availableTabs.filter { tab in
tab.title.localizedCaseInsensitiveContains(trimmed) || matchingTabs.contains(tab)
}
}
private var searchSuggestions: [SettingsSearchEntry] {
Array(searchEntries(matching: searchText).prefix(8))
}
private func searchEntries(matching query: String) -> [SettingsSearchEntry] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
return settingsSearchIndex
.filter { availableTabs.contains($0.tab) }
.filter { entry in
entry.title.localizedCaseInsensitiveContains(trimmed) ||
entry.keywords.contains { $0.localizedCaseInsensitiveContains(trimmed) }
}
}
private var settingsSearchIndex: [SettingsSearchEntry] {
[
// General
SettingsSearchEntry(tab: .general, title: "Enable Minimalistic UI", keywords: ["minimalistic", "ui mode", "general"]),
SettingsSearchEntry(tab: .general, title: "Menubar icon", keywords: ["menu bar", "status bar", "icon"]),
SettingsSearchEntry(tab: .general, title: "Launch at login", keywords: ["autostart", "startup"]),
SettingsSearchEntry(tab: .general, title: "Show on all displays", keywords: ["multi-display", "external monitor"]),
SettingsSearchEntry(tab: .general, title: "Show on a specific display", keywords: ["preferred screen", "display picker"]),
SettingsSearchEntry(tab: .general, title: "Automatically switch displays", keywords: ["auto switch", "displays"]),
SettingsSearchEntry(tab: .general, title: "Hide Dynamic Island during screenshots & recordings", keywords: ["privacy", "screenshot", "recording"]),
SettingsSearchEntry(tab: .general, title: "Enable Screen Recording Detection", keywords: ["screen recording", "indicator"]),
SettingsSearchEntry(tab: .general, title: "Show Recording Indicator", keywords: ["recording indicator", "red dot"]),
SettingsSearchEntry(tab: .general, title: "Enable Focus Detection", keywords: ["focus", "do not disturb", "dnd"]),
SettingsSearchEntry(tab: .general, title: "Show Focus Indicator", keywords: ["focus icon", "moon"]),
SettingsSearchEntry(tab: .general, title: "Show Focus Label", keywords: ["focus label", "text"]),
SettingsSearchEntry(tab: .general, title: "Enable Camera Detection", keywords: ["camera", "privacy indicator"]),
SettingsSearchEntry(tab: .general, title: "Enable Microphone Detection", keywords: ["microphone", "privacy"]),
SettingsSearchEntry(tab: .general, title: "Enable gestures", keywords: ["gestures", "trackpad"]),
SettingsSearchEntry(tab: .general, title: "Close gesture", keywords: ["pinch", "swipe"]),
SettingsSearchEntry(tab: .general, title: "Extend hover area", keywords: ["hover", "cursor"]),
SettingsSearchEntry(tab: .general, title: "Enable haptics", keywords: ["haptic", "feedback"]),
SettingsSearchEntry(tab: .general, title: "Open notch on hover", keywords: ["hover to open", "auto open"]),
SettingsSearchEntry(tab: .general, title: "Non-notch display height", keywords: ["display height", "menu bar size"]),
// Battery (Charge)
SettingsSearchEntry(tab: .battery, title: "Show battery indicator", keywords: ["battery hud", "charge"]),
SettingsSearchEntry(tab: .battery, title: "Show battery percentage", keywords: ["battery percent"]),
SettingsSearchEntry(tab: .battery, title: "Show power status notifications", keywords: ["notifications", "power"]),
SettingsSearchEntry(tab: .battery, title: "Show power status icons", keywords: ["power icons", "charging icon"]),
// Downloads
SettingsSearchEntry(tab: .downloads, title: "Show download progress", keywords: ["downloads", "progress"]),
SettingsSearchEntry(tab: .downloads, title: "Enable Safari Downloads", keywords: ["safari", "browser"]),
SettingsSearchEntry(tab: .downloads, title: "Download indicator style", keywords: ["indicator", "style"]),
SettingsSearchEntry(tab: .downloads, title: "Download icon style", keywords: ["icon", "style"]),
// HUDs
SettingsSearchEntry(tab: .hud, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"]),
SettingsSearchEntry(tab: .hud, title: "Use circular battery indicator", keywords: ["battery", "circular"]),
SettingsSearchEntry(tab: .hud, title: "Show battery percentage text in HUD", keywords: ["battery text"]),
SettingsSearchEntry(tab: .hud, title: "Scroll device name in HUD", keywords: ["marquee", "device name"]),
SettingsSearchEntry(tab: .hud, title: "Color-coded battery display", keywords: ["color", "battery"]),
SettingsSearchEntry(tab: .hud, title: "Color-coded volume display", keywords: ["volume", "color"]),
SettingsSearchEntry(tab: .hud, title: "Smooth color transitions", keywords: ["gradient", "smooth"]),
SettingsSearchEntry(tab: .hud, title: "Show percentages beside progress bars", keywords: ["percentages", "progress"]),
SettingsSearchEntry(tab: .hud, title: "HUD style", keywords: ["inline", "compact"]),
SettingsSearchEntry(tab: .hud, title: "Progressbar style", keywords: ["progress", "style"]),
SettingsSearchEntry(tab: .hud, title: "Enable glowing effect", keywords: ["glow", "indicator"]),
SettingsSearchEntry(tab: .hud, title: "Use accent color", keywords: ["accent", "color"]),
// Media
SettingsSearchEntry(tab: .media, title: "Music Source", keywords: ["media source", "controller"]),
SettingsSearchEntry(tab: .media, title: "Skip buttons", keywords: ["skip", "controls", "±10"]),
SettingsSearchEntry(tab: .media, title: "Sneak Peek Style", keywords: ["sneak peek", "preview"]),
SettingsSearchEntry(tab: .media, title: "Enable lyrics", keywords: ["lyrics", "song text"]),
// Calendar
SettingsSearchEntry(tab: .calendar, title: "Show calendar", keywords: ["calendar", "events"]),
SettingsSearchEntry(tab: .calendar, title: "Enable reminder live activity", keywords: ["reminder", "live activity"]),
SettingsSearchEntry(tab: .calendar, title: "Countdown style", keywords: ["reminder countdown"]),
SettingsSearchEntry(tab: .calendar, title: "Show lock screen reminder", keywords: ["lock screen", "reminder widget"]),
SettingsSearchEntry(tab: .calendar, title: "Chip color", keywords: ["reminder chip", "color"]),
// Shelf
SettingsSearchEntry(tab: .shelf, title: "Enable shelf", keywords: ["shelf", "dock"]),
SettingsSearchEntry(tab: .shelf, title: "Open shelf tab by default if items added", keywords: ["auto open", "shelf tab"]),
// Appearance
SettingsSearchEntry(tab: .appearance, title: "Settings icon in notch", keywords: ["settings button", "toolbar"]),
SettingsSearchEntry(tab: .appearance, title: "Enable window shadow", keywords: ["shadow", "appearance"]),
SettingsSearchEntry(tab: .appearance, title: "Corner radius scaling", keywords: ["corner radius", "shape"]),
SettingsSearchEntry(tab: .appearance, title: "Use simpler close animation", keywords: ["close animation", "notch"]),
SettingsSearchEntry(tab: .appearance, title: "Enable colored spectrograms", keywords: ["spectrogram", "audio"]),
SettingsSearchEntry(tab: .appearance, title: "Enable blur effect behind album art", keywords: ["blur", "album art"]),
SettingsSearchEntry(tab: .appearance, title: "Slider color", keywords: ["slider", "accent"]),
SettingsSearchEntry(tab: .appearance, title: "Enable Dynamic mirror", keywords: ["mirror", "reflection"]),
SettingsSearchEntry(tab: .appearance, title: "Mirror shape", keywords: ["mirror shape", "circle", "rectangle"]),
SettingsSearchEntry(tab: .appearance, title: "Show cool face animation while inactivity", keywords: ["face animation", "idle"]),
// Lock Screen
SettingsSearchEntry(tab: .lockScreen, title: "Enable lock screen live activity", keywords: ["lock screen", "live activity"]),
SettingsSearchEntry(tab: .lockScreen, title: "Play lock/unlock sounds", keywords: ["chime", "sound"]),
SettingsSearchEntry(tab: .lockScreen, title: "Material", keywords: ["glass", "frosted", "liquid"]),
SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen media panel", keywords: ["media panel", "lock screen media"]),
SettingsSearchEntry(tab: .lockScreen, title: "Show media app icon", keywords: ["app icon", "media"]),
SettingsSearchEntry(tab: .lockScreen, title: "Show panel border", keywords: ["panel border"]),
SettingsSearchEntry(tab: .lockScreen, title: "Enable media panel blur", keywords: ["blur", "media panel"]),
SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen timer", keywords: ["timer widget", "lock screen timer"]),
SettingsSearchEntry(tab: .lockScreen, title: "Enable timer blur", keywords: ["timer blur"]),
SettingsSearchEntry(tab: .lockScreen, title: "Show lock screen weather", keywords: ["weather widget"]),
SettingsSearchEntry(tab: .lockScreen, title: "Layout", keywords: ["inline", "circular", "weather layout"]),
SettingsSearchEntry(tab: .lockScreen, title: "Weather data provider", keywords: ["wttr", "open meteo"]),
SettingsSearchEntry(tab: .lockScreen, title: "Temperature unit", keywords: ["celsius", "fahrenheit"]),
SettingsSearchEntry(tab: .lockScreen, title: "Show location label", keywords: ["location", "weather"]),
SettingsSearchEntry(tab: .lockScreen, title: "Show charging status", keywords: ["charging", "weather"]),
SettingsSearchEntry(tab: .lockScreen, title: "Show charging percentage", keywords: ["charging percentage"]),
SettingsSearchEntry(tab: .lockScreen, title: "Show battery indicator", keywords: ["battery gauge", "weather"]),
SettingsSearchEntry(tab: .lockScreen, title: "Use MacBook icon when on battery", keywords: ["laptop icon", "battery"]),
SettingsSearchEntry(tab: .lockScreen, title: "Show Bluetooth battery", keywords: ["bluetooth", "gauge"]),
SettingsSearchEntry(tab: .lockScreen, title: "Show AQI widget", keywords: ["air quality", "aqi"]),
SettingsSearchEntry(tab: .lockScreen, title: "Air quality scale", keywords: ["aqi", "scale"]),
SettingsSearchEntry(tab: .lockScreen, title: "Use colored gauges", keywords: ["gauge tint", "monochrome"]),
// Shortcuts
SettingsSearchEntry(tab: .shortcuts, title: "Enable global keyboard shortcuts", keywords: ["keyboard", "shortcut"]),
// Timer
SettingsSearchEntry(tab: .timer, title: "Enable timer feature", keywords: ["timer", "enable"]),
SettingsSearchEntry(tab: .timer, title: "Mirror macOS Clock timers", keywords: ["system timer", "clock app"]),
SettingsSearchEntry(tab: .timer, title: "Show lock screen timer widget", keywords: ["lock screen", "timer widget"]),
SettingsSearchEntry(tab: .timer, title: "Enable timer blur", keywords: ["timer blur", "lock screen"]),
SettingsSearchEntry(tab: .timer, title: "Timer tint", keywords: ["timer colour", "preset"]),
SettingsSearchEntry(tab: .timer, title: "Solid colour", keywords: ["timer colour", "custom"]),
SettingsSearchEntry(tab: .timer, title: "Progress style", keywords: ["progress", "bar", "ring"]),
SettingsSearchEntry(tab: .timer, title: "Accent colour", keywords: ["accent", "timer"]),
// Stats
SettingsSearchEntry(tab: .stats, title: "Enable system stats monitoring", keywords: ["stats", "monitoring"]),
SettingsSearchEntry(tab: .stats, title: "Stop monitoring after closing the notch", keywords: ["stats", "auto stop"]),
SettingsSearchEntry(tab: .stats, title: "CPU Usage", keywords: ["cpu", "graph"]),
SettingsSearchEntry(tab: .stats, title: "Memory Usage", keywords: ["memory", "ram"]),
SettingsSearchEntry(tab: .stats, title: "GPU Usage", keywords: ["gpu", "graphics"]),
SettingsSearchEntry(tab: .stats, title: "Network Activity", keywords: ["network", "graph"]),
SettingsSearchEntry(tab: .stats, title: "Disk I/O", keywords: ["disk", "io"]),
// Clipboard
SettingsSearchEntry(tab: .clipboard, title: "Enable Clipboard Manager", keywords: ["clipboard", "manager"]),
SettingsSearchEntry(tab: .clipboard, title: "Show Clipboard Icon", keywords: ["icon", "clipboard"]),
SettingsSearchEntry(tab: .clipboard, title: "Display Mode", keywords: ["list", "grid", "clipboard"]),
SettingsSearchEntry(tab: .clipboard, title: "History Size", keywords: ["history", "clipboard"]),
// Screen Assistant
SettingsSearchEntry(tab: .screenAssistant, title: "Enable Screen Assistant", keywords: ["screen assistant", "ai"]),
SettingsSearchEntry(tab: .screenAssistant, title: "Display Mode", keywords: ["screen assistant", "mode"]),
// Color Picker
SettingsSearchEntry(tab: .colorPicker, title: "Enable Color Picker", keywords: ["color picker", "eyedropper"]),
SettingsSearchEntry(tab: .colorPicker, title: "Show Color Picker Icon", keywords: ["color icon", "toolbar"]),
SettingsSearchEntry(tab: .colorPicker, title: "Display Mode", keywords: ["color", "list"]),
SettingsSearchEntry(tab: .colorPicker, title: "History Size", keywords: ["color history"]),
SettingsSearchEntry(tab: .colorPicker, title: "Show All Color Formats", keywords: ["hex", "hsl", "color formats"])
]
}
private func isTabVisible(_ tab: SettingsTab) -> Bool {
switch tab {
case .timer, .stats, .clipboard, .screenAssistant, .colorPicker, .shelf:
return !enableMinimalisticUI
case .downloads:
return extensionManager.installedExtensions.contains { $0.bundleIdentifier == downloadManagerExtension }
default:
return true
}
}
@ViewBuilder
private func detailView(for tab: SettingsTab) -> some View {
switch tab {
case .general:
GeneralSettings()
case .appearance:
Appearance()
case .lockScreen:
LockScreenSettings()
case .media:
Media()
case .timer:
TimerSettings()
case .calendar:
CalendarSettings()
case .hud:
HUD()
case .battery:
Charge()
case .stats:
StatsSettings()
case .clipboard:
ClipboardSettings()
case .screenAssistant:
ScreenAssistantSettings()
case .colorPicker:
ColorPickerSettings()
case .downloads:
Downloads()
case .shelf:
Shelf()
case .shortcuts:
Shortcuts()
case .extensions:
Extensions()
case .about:
if let controller = updaterController {
About(updaterController: controller)
} else {
About(updaterController: SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil))
}
}
}
}
struct GeneralSettings: View {
@State private var screens: [String] = NSScreen.screens.compactMap { $0.localizedName }
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var recordingManager = ScreenRecordingManager.shared
@ObservedObject var privacyManager = PrivacyIndicatorManager.shared
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
@Default(.mirrorShape) var mirrorShape
@Default(.showEmojis) var showEmojis
@Default(.gestureSensitivity) var gestureSensitivity
@Default(.minimumHoverDuration) var minimumHoverDuration
@Default(.nonNotchHeight) var nonNotchHeight
@Default(.nonNotchHeightMode) var nonNotchHeightMode
@Default(.notchHeight) var notchHeight
@Default(.notchHeightMode) var notchHeightMode
@Default(.showOnAllDisplays) var showOnAllDisplays
@Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay
@Default(.enableGestures) var enableGestures
@Default(.openNotchOnHover) var openNotchOnHover
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
@Default(.showRecordingIndicator) var showRecordingIndicator
@Default(.enableDoNotDisturbDetection) var enableDoNotDisturbDetection
@Default(.showDoNotDisturbIndicator) var showDoNotDisturbIndicator
@Default(.showDoNotDisturbLabel) var showDoNotDisturbLabel
@Default(.enableMinimalisticUI) var enableMinimalisticUI
var body: some View {
Form {
Section {
Defaults.Toggle("Enable Minimalistic UI", key: .enableMinimalisticUI)
.onChange(of: enableMinimalisticUI) { _, newValue in
if newValue {
// Auto-enable simpler animation mode
Defaults[.useModernCloseAnimation] = true
}
}
} header: {
Text("UI Mode")
} footer: {
Text("Minimalistic mode focuses on media controls and system HUDs, hiding all extra features for a clean, focused experience. Automatically enables simpler animations.")
}
Section {
Defaults.Toggle("Menubar icon", key: .menubarIcon)
LaunchAtLogin.Toggle("Launch at login")
Defaults.Toggle(key: .showOnAllDisplays) {
Text("Show on all displays")
}
.onChange(of: showOnAllDisplays) {
NotificationCenter.default.post(name: Notification.Name.showOnAllDisplaysChanged, object: nil)
}
Picker("Show on a specific display", selection: $coordinator.preferredScreen) {
ForEach(screens, id: \.self) { screen in
Text(screen)
}
}
.onChange(of: NSScreen.screens) {
screens = NSScreen.screens.compactMap({$0.localizedName})
}
.disabled(showOnAllDisplays)
Defaults.Toggle("Automatically switch displays", key: .automaticallySwitchDisplay)
.onChange(of: automaticallySwitchDisplay) {
NotificationCenter.default.post(name: Notification.Name.automaticallySwitchDisplayChanged, object: nil)
}
.disabled(showOnAllDisplays)
Defaults.Toggle("Hide Dynamic Island during screenshots & recordings", key: .hideDynamicIslandFromScreenCapture)
} header: {
Text("System features")
}
Section {
Defaults.Toggle("Enable Screen Recording Detection", key: .enableScreenRecordingDetection)
Defaults.Toggle("Show Recording Indicator", key: .showRecordingIndicator)
.disabled(!enableScreenRecordingDetection)
// Note: Polling removed - now uses event-driven private API detection
if recordingManager.isMonitoring {
HStack {
Text("Detection Status")
Spacer()
if recordingManager.isRecording {
HStack(spacing: 4) {
Circle()
.fill(Color.red)
.frame(width: 8, height: 8)
Text("Recording Detected")
.foregroundColor(.red)
}
} else {
Text("Active - No Recording")
.foregroundColor(.green)
}
}
}
} header: {
Text("Screen Recording")
} footer: {
Text("Uses event-driven private API for real-time screen recording detection")
}
Section {
Defaults.Toggle("Enable Focus Detection", key: .enableDoNotDisturbDetection)
Defaults.Toggle("Show Focus Indicator", key: .showDoNotDisturbIndicator)
.disabled(!enableDoNotDisturbDetection)
Defaults.Toggle("Show Focus Label", key: .showDoNotDisturbLabel)
.disabled(!enableDoNotDisturbDetection)
if doNotDisturbManager.isMonitoring {
HStack {
Text("Focus Status")
Spacer()
if doNotDisturbManager.isDoNotDisturbActive {
HStack(spacing: 4) {
Circle()
.fill(Color.purple)
.frame(width: 8, height: 8)
Text(doNotDisturbManager.currentFocusModeName.isEmpty ? "Focus Enabled" : doNotDisturbManager.currentFocusModeName)
.foregroundColor(.purple)
}
} else {
Text("Active - No Focus")
.foregroundColor(.green)
}
}
} else {
HStack {
Text("Focus Status")
Spacer()
Text("Disabled")
.foregroundColor(.secondary)
}
}
} header: {
Text("Do Not Disturb")
} footer: {
Text("Listens for Focus session changes via distributed notifications")
}
Section {
Defaults.Toggle("Enable Camera Detection", key: .enableCameraDetection)
Defaults.Toggle("Enable Microphone Detection", key: .enableMicrophoneDetection)
if privacyManager.isMonitoring {
HStack {
Text("Camera Status")
Spacer()
if privacyManager.cameraActive {
HStack(spacing: 4) {
Circle()
.fill(Color.green)
.frame(width: 8, height: 8)
Text("Camera Active")
.foregroundColor(.green)
}
} else {
Text("Inactive")
.foregroundColor(.secondary)
}
}
HStack {
Text("Microphone Status")
Spacer()
if privacyManager.microphoneActive {
HStack(spacing: 4) {
Circle()
.fill(Color.yellow)
.frame(width: 8, height: 8)
Text("Microphone Active")
.foregroundColor(.yellow)
}
} else {
Text("Inactive")
.foregroundColor(.secondary)
}
}
}
} header: {
Text("Privacy Indicators")
} footer: {
Text("Shows green camera icon and yellow microphone icon when in use. Uses event-driven CoreAudio and CoreMediaIO APIs.")
}
Section {
Picker(selection: $notchHeightMode, label:
Text("Notch display height")) {
Text("Match real notch size")
.tag(WindowHeightMode.matchRealNotchSize)
Text("Match menubar height")
.tag(WindowHeightMode.matchMenuBar)
Text("Custom height")
.tag(WindowHeightMode.custom)
}
.onChange(of: notchHeightMode) {
switch notchHeightMode {
case .matchRealNotchSize:
notchHeight = 38
case .matchMenuBar:
notchHeight = 44
case .custom:
notchHeight = 38
}
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
}
if notchHeightMode == .custom {
Slider(value: $notchHeight, in: 15...45, step: 1) {
Text("Custom notch size - \(notchHeight, specifier: "%.0f")")
}
.onChange(of: notchHeight) {
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
}
}
Picker("Non-notch display height", selection: $nonNotchHeightMode) {
Text("Match menubar height")
.tag(WindowHeightMode.matchMenuBar)
Text("Match real notch size")
.tag(WindowHeightMode.matchRealNotchSize)
Text("Custom height")
.tag(WindowHeightMode.custom)
}
.onChange(of: nonNotchHeightMode) {
switch nonNotchHeightMode {
case .matchMenuBar:
nonNotchHeight = 24
case .matchRealNotchSize:
nonNotchHeight = 32
case .custom:
nonNotchHeight = 32
}
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
}
if nonNotchHeightMode == .custom {
Slider(value: $nonNotchHeight, in: 0...40, step: 1) {
Text("Custom notch size - \(nonNotchHeight, specifier: "%.0f")")
}
.onChange(of: nonNotchHeight) {
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
}
}
} header: {
Text("Notch Height")
}
NotchBehaviour()
gestureControls()
}
.toolbar {
Button("Quit app") {
NSApp.terminate(self)
}
.controlSize(.extraLarge)
}
.navigationTitle("General")
.onChange(of: openNotchOnHover) {
if !openNotchOnHover {
enableGestures = true
}
}
}
@ViewBuilder
func gestureControls() -> some View {
Section {
Defaults.Toggle("Enable gestures", key: .enableGestures)
.disabled(!openNotchOnHover)
if enableGestures {
Toggle("Media change with horizontal gestures", isOn: .constant(false))
.disabled(true)
Defaults.Toggle("Close gesture", key: .closeGestureEnabled)
Slider(value: $gestureSensitivity, in: 100...300, step: 100) {
HStack {
Text("Gesture sensitivity")
Spacer()
Text(Defaults[.gestureSensitivity] == 100 ? "High" : Defaults[.gestureSensitivity] == 200 ? "Medium" : "Low")
.foregroundStyle(.secondary)
}
}
}
} header: {
HStack {
Text("Gesture control")
customBadge(text: "Beta")
}
} footer: {
Text("Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
}
@ViewBuilder
func NotchBehaviour() -> some View {
Section {
Defaults.Toggle("Extend hover area", key: .extendHoverArea)
Defaults.Toggle("Enable haptics", key: .enableHaptics)
Defaults.Toggle("Open notch on hover", key: .openNotchOnHover)
Toggle("Remember last tab", isOn: $coordinator.openLastTabByDefault)
if openNotchOnHover {
Slider(value: $minimumHoverDuration, in: 0...1, step: 0.1) {
HStack {
Text("Minimum hover duration")
Spacer()
Text("\(minimumHoverDuration, specifier: "%.1f")s")
.foregroundStyle(.secondary)
}
}
.onChange(of: minimumHoverDuration) {
NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil)
}
}
} header: {
Text("Notch behavior")
}
}
}
struct Charge: View {
var body: some View {
Form {
Section {
Defaults.Toggle("Show battery indicator", key: .showBatteryIndicator)
Defaults.Toggle("Show power status notifications", key: .showPowerStatusNotifications)
} header: {
Text("General")
}
Section {
Defaults.Toggle("Show battery percentage", key: .showBatteryPercentage)
Defaults.Toggle("Show power status icons", key: .showPowerStatusIcons)
} header: {
Text("Battery Information")
}
}
.navigationTitle("Battery")
}
}
struct Downloads: View {
@Default(.selectedDownloadIndicatorStyle) var selectedDownloadIndicatorStyle
@Default(.selectedDownloadIconStyle) var selectedDownloadIconStyle
var body: some View {
Form {
warningBadge("We don't support downloads yet", "It will be supported later on.")
Section {
Defaults.Toggle("Show download progress", key: .enableDownloadListener)
.disabled(true)
Defaults.Toggle("Enable Safari Downloads", key: .enableSafariDownloads)
.disabled(!Defaults[.enableDownloadListener])
Picker("Download indicator style", selection: $selectedDownloadIndicatorStyle) {
Text("Progress bar")
.tag(DownloadIndicatorStyle.progress)
Text("Percentage")
.tag(DownloadIndicatorStyle.percentage)
}
Picker("Download icon style", selection: $selectedDownloadIconStyle) {
Text("Only app icon")
.tag(DownloadIconStyle.onlyAppIcon)
Text("Only download icon")
.tag(DownloadIconStyle.onlyIcon)
Text("Both")
.tag(DownloadIconStyle.iconAndAppIcon)
}
} header: {
HStack {
Text("Download indicators")
comingSoonTag()
}
}
Section {
List {
ForEach([].indices, id: \.self) { index in
Text("\(index)")
}
}
.frame(minHeight: 96)
.overlay {
if true {
Text("No excluded apps")
.foregroundStyle(Color(.secondaryLabelColor))
}
}
.actionBar(padding: 0) {
Group {
Button {} label: {
Image(systemName: "plus")
.frame(width: 25, height: 16, alignment: .center)
.contentShape(Rectangle())
.foregroundStyle(.secondary)
}
Divider()
Button {} label: {
Image(systemName: "minus")
.frame(width: 20, height: 16, alignment: .center)
.contentShape(Rectangle())
.foregroundStyle(.secondary)
}
}
}
} header: {
HStack(spacing: 4) {
Text("Exclude apps")
comingSoonTag()
}
}
}
.navigationTitle("Downloads")
}
}
struct HUD: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@Default(.inlineHUD) var inlineHUD
@Default(.progressBarStyle) var progressBarStyle
@Default(.enableSystemHUD) var enableSystemHUD
@Default(.enableVolumeHUD) var enableVolumeHUD
@Default(.enableBrightnessHUD) var enableBrightnessHUD
@Default(.enableKeyboardBacklightHUD) var enableKeyboardBacklightHUD
@Default(.systemHUDSensitivity) var systemHUDSensitivity
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
var body: some View {
Form {
Section {
Toggle("Enable HUD replacement", isOn: $coordinator.hudReplacement)
} header: {
Text("General")
} footer: {
Text("Replaces system HUD notifications with Dynamic Island displays.")
.foregroundStyle(.secondary)
.font(.caption)
}
Section {
Toggle("Enable Built-in System HUD", isOn: $enableSystemHUD)
if enableSystemHUD {
Toggle("Volume HUD", isOn: $enableVolumeHUD)
Toggle("Brightness HUD", isOn: $enableBrightnessHUD)
Toggle("Keyboard Backlight HUD", isOn: $enableKeyboardBacklightHUD)
HStack {
Text("Sensitivity")
Spacer()
Slider(value: Binding(
get: { Double(systemHUDSensitivity) },
set: { systemHUDSensitivity = Int($0) }
), in: 1...10, step: 1) {
Text("Sensitivity")
}
.frame(width: 120)
Text("\(systemHUDSensitivity)")
.foregroundStyle(.secondary)
.font(.caption)
.frame(width: 20)
}
}
} header: {
Text("Built-in System Monitoring")
} footer: {
if enableSystemHUD {
Text("Built-in system monitoring detects volume, brightness, and keyboard backlight changes directly without requiring external apps.")
.foregroundStyle(.secondary)
.font(.caption)
} else {
Text("Enable built-in system monitoring to replace macOS HUD notifications with Dynamic Island displays.")
.foregroundStyle(.secondary)
.font(.caption)
}
}
Section {
Defaults.Toggle("Show Bluetooth device connections", key: .showBluetoothDeviceConnections)
Defaults.Toggle("Use circular battery indicator", key: .useCircularBluetoothBatteryIndicator)
Defaults.Toggle("Show battery percentage text in HUD", key: .showBluetoothBatteryPercentageText)
Defaults.Toggle("Scroll device name in HUD", key: .showBluetoothDeviceNameMarquee)
} header: {
Text("Bluetooth Audio Devices")
} footer: {
Text("Displays a HUD notification when Bluetooth audio devices (headphones, AirPods, speakers) connect, showing device name and battery level.")
.foregroundStyle(.secondary)
.font(.caption)
}
Section {
let colorCodingDisabled = progressBarStyle == .segmented
Defaults.Toggle("Color-coded battery display", key: .useColorCodedBatteryDisplay)
.disabled(colorCodingDisabled)
Defaults.Toggle("Color-coded volume display", key: .useColorCodedVolumeDisplay)
.disabled(colorCodingDisabled)
if !colorCodingDisabled && (Defaults[.useColorCodedBatteryDisplay] || Defaults[.useColorCodedVolumeDisplay]) {
Defaults.Toggle("Smooth color transitions", key: .useSmoothColorGradient)
}
Defaults.Toggle("Show percentages beside progress bars", key: .showProgressPercentages)
} header: {
Text("Color-Coded Progress Bars")
} footer: {
if progressBarStyle == .segmented {
Text("Color-coded fills and smooth gradients are unavailable in Segmented mode. Switch to Hierarchical or Gradient to adjust these options.")
.foregroundStyle(.secondary)
.font(.caption)
} else if Defaults[.useSmoothColorGradient] {
Text("Smooth transitions blend Green (0–60%), Yellow (60–85%), and Red (85–100%) through the entire fill.")
.foregroundStyle(.secondary)
.font(.caption)
} else {
Text("Discrete transitions snap between Green (0–60%), Yellow (60–85%), and Red (85–100%).")
.foregroundStyle(.secondary)
.font(.caption)
}
}
Section {
Picker("HUD style", selection: $inlineHUD) {
Text("Default")
.tag(false)
Text("Inline")
.tag(true)
}
.onChange(of: Defaults[.inlineHUD]) {
if Defaults[.inlineHUD] {
withAnimation {
Defaults[.systemEventIndicatorShadow] = false
Defaults[.progressBarStyle] = .hierarchical
}
}
}
Picker("Progressbar style", selection: $progressBarStyle) {
Text("Hierarchical")
.tag(ProgressBarStyle.hierarchical)
Text("Gradient")
.tag(ProgressBarStyle.gradient)
Text("Segmented")
.tag(ProgressBarStyle.segmented)
}
Defaults.Toggle("Enable glowing effect", key: .systemEventIndicatorShadow)
Defaults.Toggle("Use accent color", key: .systemEventIndicatorUseAccent)
} header: {
HStack {
Text("Appearance")
}
}
}
.navigationTitle("HUDs")
}
}
struct Media: View {
@Default(.waitInterval) var waitInterval
@Default(.mediaController) var mediaController
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Default(.hideNotchOption) var hideNotchOption
@Default(.enableSneakPeek) private var enableSneakPeek
@Default(.sneakPeekStyles) var sneakPeekStyles
@Default(.enableMinimalisticUI) var enableMinimalisticUI
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
@Default(.musicAuxLeftControl) private var musicAuxLeftControl
@Default(.musicAuxRightControl) private var musicAuxRightControl
@Default(.musicSkipBehavior) private var musicSkipBehavior
@Default(.musicControlWindowEnabled) private var musicControlWindowEnabled
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
@State private var previousLeftAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxLeftControl]
@State private var previousRightAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxRightControl]
var body: some View {
Form {
Section {
Picker("Music Source", selection: $mediaController) {
ForEach(availableMediaControllers) { controller in
Text(controller.rawValue).tag(controller)
}
}
.onChange(of: mediaController) { _, _ in
NotificationCenter.default.post(
name: Notification.Name.mediaControllerChanged,
object: nil
)
}
} header: {
Text("Media Source")
} footer: {
if MusicManager.shared.isNowPlayingDeprecated {
HStack {
Text("YouTube Music requires this third-party app to be installed: ")
.foregroundStyle(.secondary)
.font(.caption)
Link("https://github.com/th-ch/youtube-music", destination: URL(string: "https://github.com/th-ch/youtube-music")!)
.font(.caption)
.foregroundColor(.blue) // Ensures it's visibly a link
}
} else {
Text("'Now Playing' was the only option on previous versions and works with all media apps.")
.foregroundStyle(.secondary)
.font(.caption)
}
}
Section {
Defaults.Toggle(key: .showShuffleAndRepeat) {
HStack {
Text("Show shuffle and repeat buttons")
customBadge(text: "Beta")
}
}
Defaults.Toggle(key: .showMediaOutputControl) {
Text("Show media output control in other layouts")
}
.disabled(!showShuffleAndRepeat)
if showShuffleAndRepeat {
mediaControlsGrid
}
} header: {
Text("Media controls")
}
Section {
Picker("Skip buttons", selection: $musicSkipBehavior) {
ForEach(MusicSkipBehavior.allCases) { behavior in
Text(behavior.displayName).tag(behavior)
}
}
.pickerStyle(.segmented)
Text(musicSkipBehavior.description)
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Skip behaviour")
}
Section {
Toggle(
"Enable music live activity",
isOn: $coordinator.musicLiveActivityEnabled.animation()
)
Defaults.Toggle(
"Show floating media controls",
key: .musicControlWindowEnabled
)
.disabled(!coordinator.musicLiveActivityEnabled)
.help("Displays play/pause and skip buttons beside the notch while music is active. Disabled by default.")
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
Defaults.Toggle("Enable lyrics", key: .enableLyrics)
Picker("Sneak Peek Style", selection: $sneakPeekStyles){
ForEach(SneakPeekStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.disabled(!enableSneakPeek || enableMinimalisticUI)
.onChange(of: enableMinimalisticUI) { _, isMinimalistic in
// Force standard sneak peek style when minimalistic UI is enabled
if isMinimalistic {
sneakPeekStyles = .standard
}
}
if enableMinimalisticUI {
Text("Sneak peek style is locked to Standard in minimalistic mode")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack {
Stepper(value: $waitInterval, in: 0...10, step: 1) {
HStack {
Text("Media inactivity timeout")
Spacer()
Text("\(Defaults[.waitInterval], specifier: "%.0f") seconds")
.foregroundStyle(.secondary)
}
}
}
} header: {
Text("Media playback live activity")
}
Section {
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon)
.disabled(!enableLockScreenMediaWidget)
Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder)
.disabled(!enableLockScreenMediaWidget)
Defaults.Toggle("Enable media panel blur", key: .lockScreenPanelUsesBlur)
.disabled(!enableLockScreenMediaWidget)
} header: {
Text("Lock Screen Integration")
} footer: {
Text("These controls mirror the Lock Screen tab so you can tune the media overlay while focusing on playback settings.")
}
Picker(selection: $hideNotchOption, label:
HStack {
Text("Hide DynamicIsland Options")
customBadge(text: "Beta")
}) {
Text("Always hide in fullscreen").tag(HideNotchOption.always)
Text("Hide only when NowPlaying app is in fullscreen").tag(HideNotchOption.nowPlayingOnly)
Text("Never hide").tag(HideNotchOption.never)
}
.onChange(of: hideNotchOption) {
Defaults[.enableFullscreenMediaDetection] = hideNotchOption != .never
}
}
.onAppear {
ensureAuxControlsUnique()
previousLeftAuxControl = musicAuxLeftControl
previousRightAuxControl = musicAuxRightControl
}
.onChange(of: musicAuxLeftControl) { newValue in
if newValue == musicAuxRightControl {
let fallback = MusicAuxiliaryControl.alternative(
excluding: newValue,
preferring: previousLeftAuxControl
)
if fallback != musicAuxRightControl {
musicAuxRightControl = fallback
}
}
previousLeftAuxControl = newValue
}
.onChange(of: musicAuxRightControl) { newValue in
if newValue == musicAuxLeftControl {
let fallback = MusicAuxiliaryControl.alternative(
excluding: newValue,
preferring: previousRightAuxControl
)
if fallback != musicAuxLeftControl {
musicAuxLeftControl = fallback
}
}
previousRightAuxControl = newValue
}
.onChange(of: showShuffleAndRepeat) { isEnabled in
if isEnabled {
ensureAuxControlsUnique()
}
}
.navigationTitle("Media")
}
private var mediaControlsGrid: some View {
VStack(alignment: .leading, spacing: 20) {
mediaControlsRow(title: "Left button", selection: $musicAuxLeftControl, otherSelection: musicAuxRightControl)
mediaControlsRow(title: "Right button", selection: $musicAuxRightControl, otherSelection: musicAuxLeftControl)
}
.padding(.vertical, 12)
}
private func mediaControlsRow(title: String, selection: Binding<MusicAuxiliaryControl>, otherSelection: MusicAuxiliaryControl) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.white)
HStack(spacing: 16) {
ForEach([MusicAuxiliaryControl.mediaOutput, MusicAuxiliaryControl.repeatMode, MusicAuxiliaryControl.lyrics, MusicAuxiliaryControl.shuffle], id: \.self) { control in
MediaControlButton(
control: control,
isSelected: selection.wrappedValue == control,
disabled: control == otherSelection
) {
selection.wrappedValue = control
}
}
}
}
}
struct MediaControlButton: View {
let control: MusicAuxiliaryControl
let isSelected: Bool
let disabled: Bool
let action: () -> Void
@State private var isHovering = false
var body: some View {
VStack(spacing: 8) {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1)
)
Image(systemName: control.symbolName)
.font(.system(size: 22, weight: .medium))
.foregroundStyle(iconColor)
}
.frame(width: 80, height: 60)
.onHover { hovering in
if !disabled {
withAnimation(.easeInOut(duration: 0.2)) {
isHovering = hovering
}
}
}
Text(control.displayName)
.font(.caption)
.fontWeight(.medium)
.lineLimit(2)
.multilineTextAlignment(.center)
.frame(width: 100)
.foregroundStyle(disabled ? .secondary : .primary)
}
.contentShape(Rectangle())
.onTapGesture {
if !disabled {
action()
}
}
.opacity(disabled ? 0.5 : 1.0)
}
private var backgroundColor: Color {
if disabled { return Color(nsColor: .controlBackgroundColor) }
if isSelected { return Color.accentColor.opacity(0.1) }
if isHovering { return Color.primary.opacity(0.05) }
return Color(nsColor: .controlBackgroundColor)
}
private var borderColor: Color {
if isSelected { return Color.accentColor }
if isHovering { return Color.primary.opacity(0.1) }
return Color.clear
}
private var iconColor: Color {
if disabled { return .secondary }
if isSelected { return .accentColor }
return .primary
}
}
// Only show controller options that are available on this macOS version
private var availableMediaControllers: [MediaControllerType] {
if MusicManager.shared.isNowPlayingDeprecated {
return MediaControllerType.allCases.filter { $0 != .nowPlaying }
} else {
return MediaControllerType.allCases
}
}
private func ensureAuxControlsUnique() {
guard showShuffleAndRepeat, musicAuxLeftControl == musicAuxRightControl else { return }
let fallback = MusicAuxiliaryControl.alternative(excluding: musicAuxLeftControl)
if fallback != musicAuxRightControl {
musicAuxRightControl = fallback
}
}
}
struct CalendarSettings: View {
@ObservedObject private var calendarManager = CalendarManager.shared
@Default(.showCalendar) var showCalendar: Bool
@Default(.enableReminderLiveActivity) var enableReminderLiveActivity
@Default(.reminderPresentationStyle) var reminderPresentationStyle
@Default(.reminderLeadTime) var reminderLeadTime
@Default(.reminderSneakPeekDuration) var reminderSneakPeekDuration
@Default(.enableLockScreenReminderWidget) var enableLockScreenReminderWidget
@Default(.lockScreenReminderChipStyle) var lockScreenReminderChipStyle
var body: some View {
Form {
if !calendarManager.hasCalendarAccess || !calendarManager.hasReminderAccess {
Text("Calendar or Reminder access is denied. Please enable it in System Settings.")
.foregroundColor(.red)
.multilineTextAlignment(.center)
.padding()
HStack {
Button("Request Access") {
Task {
await calendarManager.checkCalendarAuthorization(forceReload: true)
await calendarManager.checkReminderAuthorization(forceReload: true)
}
}
.buttonStyle(.borderedProminent)
Button("Open System Settings") {
if let settingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") {
NSWorkspace.shared.open(settingsURL)
}
}
}
} else {
// Permissions status
Section {
HStack {
Text("Calendars")
Spacer()
Text(statusText(for: calendarManager.calendarAuthorizationStatus))
.foregroundColor(color(for: calendarManager.calendarAuthorizationStatus))
}
HStack {
Text("Reminders")
Spacer()
Text(statusText(for: calendarManager.reminderAuthorizationStatus))
.foregroundColor(color(for: calendarManager.reminderAuthorizationStatus))
}
} header: {
Text("Permissions")
}
Defaults.Toggle("Show calendar", key: .showCalendar)
Section(header: Text("Reminder Live Activity")) {
Defaults.Toggle("Enable reminder live activity", key: .enableReminderLiveActivity)
Picker("Countdown style", selection: $reminderPresentationStyle) {
ForEach(ReminderPresentationStyle.allCases) { style in
Text(style.displayName).tag(style)
}
}
.pickerStyle(.segmented)
.disabled(!enableReminderLiveActivity)
HStack {
Text("Notify before")
Slider(
value: Binding(
get: { Double(reminderLeadTime) },
set: { reminderLeadTime = Int($0) }
),
in: 1...60,
step: 1
)
.disabled(!enableReminderLiveActivity)
Text("\(reminderLeadTime) min")
.foregroundStyle(.secondary)
.frame(width: 60, alignment: .trailing)
}
HStack {
Text("Sneak peek duration")
Slider(
value: $reminderSneakPeekDuration,
in: 3...20,
step: 1
)
.disabled(!enableReminderLiveActivity)
Text("\(Int(reminderSneakPeekDuration)) s")
.foregroundStyle(.secondary)
.frame(width: 60, alignment: .trailing)
}
}
Section(header: Text("Lock Screen Reminder Widget")) {
Defaults.Toggle("Show lock screen reminder", key: .enableLockScreenReminderWidget)
Picker("Chip color", selection: $lockScreenReminderChipStyle) {
ForEach(LockScreenReminderChipStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.pickerStyle(.segmented)
.disabled(!enableLockScreenReminderWidget || !enableReminderLiveActivity)
}
Section(header: Text("Select Calendars")) {
List {
ForEach(calendarManager.allCalendars, id: \.id) { calendar in
Toggle(isOn: Binding(
get: { calendarManager.getCalendarSelected(calendar) },
set: { isSelected in
Task {
await calendarManager.setCalendarSelected(calendar, isSelected: isSelected)
}
}
)) {
Text(calendar.title)
}
.disabled(!showCalendar)
}
}
}
}
}
.onAppear {
Task {
await calendarManager.checkCalendarAuthorization()
await calendarManager.checkReminderAuthorization()
}
}
.navigationTitle("Calendar")
}
private func statusText(for status: EKAuthorizationStatus) -> String {
switch status {
case .fullAccess, .authorized: return "Full Access"
case .writeOnly: return "Write Only"
case .denied: return "Denied"
case .restricted: return "Restricted"
case .notDetermined: return "Not Determined"
@unknown default: return "Unknown"
}
}
private func color(for status: EKAuthorizationStatus) -> Color {
switch status {
case .fullAccess, .authorized: return .green
case .writeOnly: return .yellow
case .denied, .restricted: return .red
case .notDetermined: return .secondary
@unknown default: return .secondary
}
}
}
struct About: View {
@State private var showBuildNumber: Bool = false
let updaterController: SPUStandardUpdaterController
@Environment(\.openWindow) var openWindow
var body: some View {
VStack {
Form {
Section {
HStack {
Text("Release name")
Spacer()
Text(Defaults[.releaseName])
.foregroundStyle(.secondary)
}
HStack {
Text("Version")
Spacer()
if showBuildNumber {
Text("(\(Bundle.main.buildVersionNumber ?? ""))")
.foregroundStyle(.secondary)
}
Text(Bundle.main.releaseVersionNumber ?? "unkown")
.foregroundStyle(.secondary)
}
.onTapGesture {
withAnimation {
showBuildNumber.toggle()
}
}
} header: {
Text("Version info")
}
UpdaterSettingsView(updater: updaterController.updater)
HStack(spacing: 30) {
Spacer(minLength: 0)
Button {
NSWorkspace.shared.open(sponsorPage)
} label: {
VStack(spacing: 5) {
Image("LinkedIn")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 18)
Text("LinkedIn")
.foregroundStyle(.white)
}
.contentShape(Rectangle())
}
Spacer(minLength: 0)
Button {
NSWorkspace.shared.open(productPage)
} label: {
VStack(spacing: 5) {
Image("Github")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 18)
Text("GitHub")
.foregroundStyle(.white)
}
.contentShape(Rectangle())
}
Spacer(minLength: 0)
}
.buttonStyle(PlainButtonStyle())
}
VStack(spacing: 0) {
Divider()
Text("Made with ❤️ by Ebullioscopic")
.foregroundStyle(.secondary)
.padding(.top, 5)
.padding(.bottom, 7)
.multilineTextAlignment(.center)
.padding(.horizontal, 10)
}
.frame(maxWidth: .infinity, alignment: .center)
}
.toolbar {
// Button("Welcome window") {
// openWindow(id: "onboarding")
// }
// .controlSize(.extraLarge)
CheckForUpdatesView(updater: updaterController.updater)
}
.navigationTitle("About")
}
}
struct Shelf: View {
var body: some View {
Form {
Section {
Defaults.Toggle("Enable shelf", key: .dynamicShelf)
Defaults.Toggle("Open shelf tab by default if items added", key: .openShelfByDefault)
} header: {
HStack {
Text("General")
}
}
}
.navigationTitle("Shelf")
}
}
struct Extensions: View {
@EnvironmentObject var extensionManager: DynamicIslandExtensionManager
@State private var effectTrigger: Bool = false
var body: some View {
Form {
//warningBadge("We don't support extensions yet") // Uhhhh You do? <><><> Oori.S
Section {
List {
ForEach(extensionManager.installedExtensions.indices, id: \.self) { index in
let item = extensionManager.installedExtensions[index]
HStack {
AppIcon(for: item.bundleIdentifier)
.resizable()
.frame(width: 24, height: 24)
Text(item.name)
ListItemPopover {
Text("Description")
}
Spacer(minLength: 0)
HStack(spacing: 6) {
Circle()
.frame(width: 6, height: 6)
.foregroundColor(isExtensionRunning(item.bundleIdentifier) ? .green : item.status == .disabled ? .gray : .red)
.conditionalModifier(isExtensionRunning(item.bundleIdentifier)) { view in
view
.shadow(color: .green, radius: 3)
}
Text(isExtensionRunning(item.bundleIdentifier) ? "Running" : item.status == .disabled ? "Disabled" : "Stopped")
.contentTransition(.numericText())
.foregroundStyle(.secondary)
.font(.footnote)
}
.frame(width: 60, alignment: .leading)
Menu(content: {
Button("Restart") {
let ws = NSWorkspace.shared
if let ext = ws.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) {
ext.terminate()
}
if let appURL = ws.urlForApplication(withBundleIdentifier: item.bundleIdentifier) {
ws.openApplication(at: appURL, configuration: .init(), completionHandler: nil)
}
}
.keyboardShortcut("R", modifiers: .command)
Button("Disable") {
if let ext = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) {
ext.terminate()
}
extensionManager.installedExtensions[index].status = .disabled
}
.keyboardShortcut("D", modifiers: .command)
Divider()
Button("Uninstall", role: .destructive) {
//
}
}, label: {
Image(systemName: "ellipsis.circle")
.foregroundStyle(.secondary)
})
.controlSize(.regular)
}
.buttonStyle(PlainButtonStyle())
.padding(.vertical, 5)
}
}
.frame(minHeight: 120)
.actionBar {
Button {} label: {
HStack(spacing: 3) {
Image(systemName: "plus")
Text("Add manually")
}
.foregroundStyle(.secondary)
}
.disabled(true)
Spacer()
Button {
withAnimation(.linear(duration: 1)) {
effectTrigger.toggle()
} completion: {
effectTrigger.toggle()
}
extensionManager.checkIfExtensionsAreInstalled()
} label: {
HStack(spacing: 3) {
Image(systemName: "arrow.triangle.2.circlepath")
.rotationEffect(effectTrigger ? .degrees(360) : .zero)
}
.foregroundStyle(.secondary)
}
}
.controlSize(.small)
.buttonStyle(PlainButtonStyle())
.overlay {
if extensionManager.installedExtensions.isEmpty {
Text("No extension installed")
.foregroundStyle(Color(.secondaryLabelColor))
.padding(.bottom, 22)
}
}
} header: {
HStack(spacing: 0) {
Text("Installed extensions")
if !extensionManager.installedExtensions.isEmpty {
Text(" – \(extensionManager.installedExtensions.count)")
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Extensions")
// TipsView()
// .padding(.horizontal, 19)
}
}
struct Appearance: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Default(.mirrorShape) var mirrorShape
@Default(.sliderColor) var sliderColor
@Default(.useMusicVisualizer) var useMusicVisualizer
@Default(.customVisualizers) var customVisualizers
@Default(.selectedVisualizer) var selectedVisualizer
let icons: [String] = ["logo2"]
@State private var selectedIcon: String = "logo2"
@State private var selectedListVisualizer: CustomVisualizer? = nil
@State private var isPresented: Bool = false
@State private var name: String = ""
@State private var url: String = ""
@State private var speed: CGFloat = 1.0
var body: some View {
Form {
Section {
Toggle("Always show tabs", isOn: $coordinator.alwaysShowTabs)
Defaults.Toggle("Settings icon in notch", key: .settingsIconInNotch)
Defaults.Toggle("Enable window shadow", key: .enableShadow)
Defaults.Toggle("Corner radius scaling", key: .cornerRadiusScaling)
Defaults.Toggle("Use simpler close animation", key: .useModernCloseAnimation)
} header: {
Text("General")
}
Section {
Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram)
Defaults
.Toggle("Player tinting", key: .playerColorTinting)
Defaults.Toggle("Enable blur effect behind album art", key: .lightingEffect)
Picker("Slider color", selection: $sliderColor) {
ForEach(SliderColorEnum.allCases, id: \.self) { option in
Text(option.rawValue)
}
}
} header: {
Text("Media")
}
Section {
Toggle(
"Use music visualizer spectrogram",
isOn: $useMusicVisualizer.animation()
)
.disabled(true)
if !useMusicVisualizer {
if customVisualizers.count > 0 {
Picker(
"Selected animation",
selection: $selectedVisualizer
) {
ForEach(
customVisualizers,
id: \.self
) { visualizer in
Text(visualizer.name)
.tag(visualizer)
}
}
} else {
HStack {
Text("Selected animation")
Spacer()
Text("No custom animation available")
.foregroundStyle(.secondary)
}
}
}
} header: {
HStack {
Text("Custom music live activity animation")
customBadge(text: "Coming soon")
}
}
Section {
List {
ForEach(customVisualizers, id: \.self) { visualizer in
HStack {
LottieView(state: LUStateData(type: .loadedFrom(visualizer.url), speed: visualizer.speed, loopMode: .loop))
.frame(width: 30, height: 30, alignment: .center)
Text(visualizer.name)
Spacer(minLength: 0)
if selectedVisualizer == visualizer {
Text("selected")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
.padding(.trailing, 8)
}
}
.buttonStyle(PlainButtonStyle())
.padding(.vertical, 2)
.background(
selectedListVisualizer != nil ? selectedListVisualizer == visualizer ? Color.accentColor : Color.clear : Color.clear,
in: RoundedRectangle(cornerRadius: 5)
)
.contentShape(Rectangle())
.onTapGesture {
if selectedListVisualizer == visualizer {
selectedListVisualizer = nil
return
}
selectedListVisualizer = visualizer
}
}
}
.safeAreaPadding(
EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0)
)
.frame(minHeight: 120)
.actionBar {
HStack(spacing: 5) {
Button {
name = ""
url = ""
speed = 1.0
isPresented.toggle()
} label: {
Image(systemName: "plus")
.foregroundStyle(.secondary)
.contentShape(Rectangle())
}
Divider()
Button {
if selectedListVisualizer != nil {
let visualizer = selectedListVisualizer!
selectedListVisualizer = nil
customVisualizers.remove(at: customVisualizers.firstIndex(of: visualizer)!)
if visualizer == selectedVisualizer && customVisualizers.count > 0 {
selectedVisualizer = customVisualizers[0]
}
}
} label: {
Image(systemName: "minus")
.foregroundStyle(.secondary)
.contentShape(Rectangle())
}
}
}
.controlSize(.small)
.buttonStyle(PlainButtonStyle())
.overlay {
if customVisualizers.isEmpty {
Text("No custom visualizer")
.foregroundStyle(Color(.secondaryLabelColor))
.padding(.bottom, 22)
}
}
.sheet(isPresented: $isPresented) {
VStack(alignment: .leading) {
Text("Add new visualizer")
.font(.largeTitle.bold())
.padding(.vertical)
TextField("Name", text: $name)
TextField("Lottie JSON URL", text: $url)
HStack {
Text("Speed")
Spacer(minLength: 80)
Text("\(speed, specifier: "%.1f")s")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
Slider(value: $speed, in: 0...2, step: 0.1)
}
.padding(.vertical)
HStack {
Button {
isPresented.toggle()
} label: {
Text("Cancel")
.frame(maxWidth: .infinity, alignment: .center)
}
Button {
let visualizer: CustomVisualizer = .init(
UUID: UUID(),
name: name,
url: URL(string: url)!,
speed: speed
)
if !customVisualizers.contains(visualizer) {
customVisualizers.append(visualizer)
}
isPresented.toggle()
} label: {
Text("Add")
.frame(maxWidth: .infinity, alignment: .center)
}
.buttonStyle(BorderedProminentButtonStyle())
}
}
.textFieldStyle(RoundedBorderTextFieldStyle())
.controlSize(.extraLarge)
.padding()
}
} header: {
HStack(spacing: 0) {
Text("Custom vizualizers (Lottie)")
if !Defaults[.customVisualizers].isEmpty {
Text(" – \(Defaults[.customVisualizers].count)")
.foregroundStyle(.secondary)
}
}
}
Section {
Defaults.Toggle("Enable Dynamic mirror", key: .showMirror)
.disabled(!checkVideoInput())
Picker("Mirror shape", selection: $mirrorShape) {
Text("Circle")
.tag(MirrorShapeEnum.circle)
Text("Square")
.tag(MirrorShapeEnum.rectangle)
}
Defaults.Toggle("Show cool face animation while inactivity", key: .showNotHumanFace)
} header: {
HStack {
Text("Additional features")
}
}
// MARK: - Custom Idle Animations Section
IdleAnimationsSettingsSection()
Section {
HStack {
ForEach(icons, id: \.self) { icon in
Spacer()
VStack {
Image(icon)
.resizable()
.frame(width: 80, height: 80)
.background(
RoundedRectangle(cornerRadius: 20, style: .circular)
.strokeBorder(
icon == selectedIcon ? Color.accentColor : .clear,
lineWidth: 2.5
)
)
Text("Default")
.fontWeight(.medium)
.font(.caption)
.foregroundStyle(icon == selectedIcon ? .white : .secondary)
.padding(.horizontal, 10)
.padding(.vertical, 3)
.background(
Capsule()
.fill(icon == selectedIcon ? Color.accentColor : .clear)
)
}
.onTapGesture {
withAnimation {
selectedIcon = icon
}
NSApp.applicationIconImage = NSImage(named: icon)
}
Spacer()
}
}
.disabled(true)
} header: {
HStack {
Text("App icon")
customBadge(text: "Coming soon")
}
}
}
.navigationTitle("Appearance")
}
func checkVideoInput() -> Bool {
if let _ = AVCaptureDevice.default(for: .video) {
return true
}
return false
}
}
struct LockScreenSettings: View {
@Default(.lockScreenGlassStyle) private var lockScreenGlassStyle
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
@Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget
@Default(.lockScreenWeatherWidgetStyle) private var lockScreenWeatherWidgetStyle
@Default(.lockScreenWeatherProviderSource) private var lockScreenWeatherProviderSource
@Default(.lockScreenWeatherTemperatureUnit) private var lockScreenWeatherTemperatureUnit
@Default(.lockScreenWeatherShowsCharging) private var lockScreenWeatherShowsCharging
@Default(.lockScreenWeatherShowsBatteryGauge) private var lockScreenWeatherShowsBatteryGauge
@Default(.lockScreenWeatherShowsAQI) private var lockScreenWeatherShowsAQI
@Default(.lockScreenWeatherAQIScale) private var lockScreenWeatherAQIScale
var body: some View {
Form {
Section {
Defaults.Toggle("Enable lock screen live activity", key: .enableLockScreenLiveActivity)
Defaults.Toggle("Play lock/unlock sounds", key: .enableLockSounds)
} header: {
Text("Live Activity & Feedback")
} footer: {
Text("Controls whether Dynamic Island mirrors lock/unlock events with its own live activity and audible chimes.")
}
Section {
if #available(macOS 26.0, *) {
Picker("Material", selection: $lockScreenGlassStyle) {
ForEach(LockScreenGlassStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
} else {
Picker("Material", selection: $lockScreenGlassStyle) {
ForEach(LockScreenGlassStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.disabled(true)
Text("Liquid Glass requires macOS 26 or later.")
.font(.caption)
.foregroundStyle(.secondary)
}
Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget)
Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon)
.disabled(!enableLockScreenMediaWidget)
Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder)
.disabled(!enableLockScreenMediaWidget)
Defaults.Toggle("Enable media panel blur", key: .lockScreenPanelUsesBlur)
.disabled(!enableLockScreenMediaWidget)
} header: {
Text("Media Panel")
} footer: {
Text("Enable and style the media controls that appear above the system clock when the screen is locked.")
}
Section {
Defaults.Toggle("Show lock screen timer", key: .enableLockScreenTimerWidget)
Defaults.Toggle("Enable timer blur", key: .lockScreenTimerWidgetUsesBlur)
.disabled(!enableLockScreenTimerWidget)
} header: {
Text("Timer Widget")
} footer: {
Text("Controls the optional timer widget that floats above the media panel. Blur adds a frosted finish behind the compact view.")
}
Section {
Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget)
if enableLockScreenWeatherWidget {
Picker("Layout", selection: $lockScreenWeatherWidgetStyle) {
ForEach(LockScreenWeatherWidgetStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.pickerStyle(.segmented)
Picker("Weather data provider", selection: $lockScreenWeatherProviderSource) {
ForEach(LockScreenWeatherProviderSource.allCases) { source in
Text(source.displayName).tag(source)
}
}
.pickerStyle(.segmented)
Picker("Temperature unit", selection: $lockScreenWeatherTemperatureUnit) {
ForEach(LockScreenWeatherTemperatureUnit.allCases) { unit in
Text(unit.rawValue).tag(unit)
}
}
.pickerStyle(.segmented)
Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation)
.disabled(lockScreenWeatherWidgetStyle == .circular)
Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging)
if lockScreenWeatherShowsCharging {
Defaults.Toggle("Show charging percentage", key: .lockScreenWeatherShowsChargingPercentage)
}
Defaults.Toggle("Show battery indicator", key: .lockScreenWeatherShowsBatteryGauge)
if lockScreenWeatherShowsBatteryGauge {
Defaults.Toggle("Use MacBook icon when on battery", key: .lockScreenWeatherBatteryUsesLaptopSymbol)
.disabled(lockScreenWeatherWidgetStyle != .circular)
if lockScreenWeatherWidgetStyle != .circular {
Text("The MacBook icon is available only in the circular layout.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth)
Defaults.Toggle("Show AQI widget", key: .lockScreenWeatherShowsAQI)
.disabled(!lockScreenWeatherProviderSource.supportsAirQuality)
if lockScreenWeatherShowsAQI && lockScreenWeatherProviderSource.supportsAirQuality {
Picker("Air quality scale", selection: $lockScreenWeatherAQIScale) {
ForEach(LockScreenWeatherAirQualityScale.allCases) { scale in
Text(scale.displayName).tag(scale)
}
}
.pickerStyle(.segmented)
}
if !lockScreenWeatherProviderSource.supportsAirQuality {
Text("Air quality requires the Open Meteo provider.")
.font(.caption)
.foregroundStyle(.secondary)
}
Defaults.Toggle("Use colored gauges", key: .lockScreenWeatherUsesGaugeTint)
}
} header: {
Text("Weather Widget")
} footer: {
Text("Enable the weather capsule and configure its layout, provider, units, and optional battery/AQI indicators.")
}
LockScreenPositioningControls()
Section {
Button("Copy Latest Crash Report") {
copyLatestCrashReport()
}
} header: {
Text("Diagnostics")
} footer: {
Text("Collect the latest crash report to share with the developer when reporting lock screen or overlay issues.")
}
}
.navigationTitle("Lock Screen")
}
}
private struct LockScreenPositioningControls: View {
@Default(.lockScreenWeatherVerticalOffset) private var weatherOffset
@Default(.lockScreenMusicVerticalOffset) private var musicOffset
@Default(.lockScreenTimerVerticalOffset) private var timerOffset
private let offsetRange: ClosedRange<Double> = -160...160
var body: some View {
Section {
let weatherBinding = Binding<Double>(
get: { weatherOffset },
set: { newValue in
let clampedValue = clamp(newValue)
if weatherOffset != clampedValue {
weatherOffset = clampedValue
}
propagateWeatherOffsetChange(animated: false)
}
)
let timerBinding = Binding<Double>(
get: { timerOffset },
set: { newValue in
let clampedValue = clamp(newValue)
if timerOffset != clampedValue {
timerOffset = clampedValue
}
propagateTimerOffsetChange(animated: false)
}
)
let musicBinding = Binding<Double>(
get: { musicOffset },
set: { newValue in
let clampedValue = clamp(newValue)
if musicOffset != clampedValue {
musicOffset = clampedValue
}
propagateMusicOffsetChange(animated: false)
}
)
LockScreenPositioningPreview(weatherOffset: weatherBinding, timerOffset: timerBinding, musicOffset: musicBinding)
.frame(height: 260)
.padding(.vertical, 8)
HStack(alignment: .top, spacing: 24) {
offsetColumn(
title: "Weather",
value: weatherOffset,
resetTitle: "Reset Weather",
resetAction: resetWeatherOffset
)
Divider()
.frame(height: 64)
offsetColumn(
title: "Timer",
value: timerOffset,
resetTitle: "Reset Timer",
resetAction: resetTimerOffset
)
Divider()
.frame(height: 64)
offsetColumn(
title: "Music",
value: musicOffset,
resetTitle: "Reset Music",
resetAction: resetMusicOffset
)
Spacer()
}
} header: {
Text("Lock Screen Positioning")
} footer: {
Text("Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Changes apply instantly while the widgets are visible.")
.textCase(nil)
}
}
private func clamp(_ value: Double) -> Double {
min(max(value, offsetRange.lowerBound), offsetRange.upperBound)
}
private func resetWeatherOffset() {
weatherOffset = 0
propagateWeatherOffsetChange(animated: true)
}
private func resetTimerOffset() {
timerOffset = 0
propagateTimerOffsetChange(animated: true)
}
private func resetMusicOffset() {
musicOffset = 0
propagateMusicOffsetChange(animated: true)
}
private func propagateWeatherOffsetChange(animated: Bool) {
Task { @MainActor in
LockScreenWeatherPanelManager.shared.refreshPositionForOffsets(animated: animated)
}
}
private func propagateTimerOffsetChange(animated: Bool) {
Task { @MainActor in
LockScreenTimerWidgetManager.shared.refreshPositionForOffsets(animated: animated)
}
}
private func propagateMusicOffsetChange(animated: Bool) {
Task { @MainActor in
LockScreenPanelManager.shared.applyOffsetAdjustment(animated: animated)
}
}
@ViewBuilder
private func offsetColumn(title: String, value: Double, resetTitle: String, resetAction: @escaping () -> Void) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("\(title) Offset")
.font(.subheadline.weight(.semibold))
Text("\(formattedPoints(value)) pt")
.font(.caption)
.foregroundStyle(.secondary)
Button(resetTitle) {
resetAction()
}
.buttonStyle(.bordered)
}
}
private func formattedPoints(_ value: Double) -> String {
String(format: "%+.0f", value)
}
}
private struct LockScreenPositioningPreview: View {
@Binding var weatherOffset: Double
@Binding var timerOffset: Double
@Binding var musicOffset: Double
@State private var weatherStartOffset: Double = 0
@State private var timerStartOffset: Double = 0
@State private var musicStartOffset: Double = 0
@State private var isWeatherDragging = false
@State private var isTimerDragging = false
@State private var isMusicDragging = false
private let offsetRange: ClosedRange<Double> = -160...160
var body: some View {
GeometryReader { geometry in
let screenPadding: CGFloat = 26
let screenCornerRadius: CGFloat = 28
let screenRect = CGRect(
x: screenPadding,
y: screenPadding,
width: geometry.size.width - (screenPadding * 2),
height: geometry.size.height - (screenPadding * 2)
)
let centerX = screenRect.midX
let weatherBaseY = screenRect.minY + (screenRect.height * 0.28)
let timerBaseY = screenRect.minY + (screenRect.height * 0.5)
let musicBaseY = screenRect.minY + (screenRect.height * 0.78)
let weatherSize = CGSize(width: screenRect.width * 0.42, height: screenRect.height * 0.22)
let timerSize = CGSize(width: screenRect.width * 0.5, height: screenRect.height * 0.2)
let musicSize = CGSize(width: screenRect.width * 0.56, height: screenRect.height * 0.34)
ZStack {
RoundedRectangle(cornerRadius: screenCornerRadius, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.55))
.frame(width: screenRect.width, height: screenRect.height)
.overlay(
RoundedRectangle(cornerRadius: screenCornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.22), radius: 20, x: 0, y: 18)
.position(x: screenRect.midX, y: screenRect.midY)
weatherPanel(size: weatherSize)
.position(x: centerX, y: weatherBaseY - CGFloat(weatherOffset))
.gesture(weatherDragGesture(in: screenRect, baseY: weatherBaseY, panelSize: weatherSize))
timerPanel(size: timerSize)
.position(x: centerX, y: timerBaseY - CGFloat(timerOffset))
.gesture(timerDragGesture(in: screenRect, baseY: timerBaseY, panelSize: timerSize))
musicPanel(size: musicSize)
.position(x: centerX, y: musicBaseY - CGFloat(musicOffset))
.gesture(musicDragGesture(in: screenRect, baseY: musicBaseY, panelSize: musicSize))
}
}
.animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: weatherOffset)
.animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: musicOffset)
}
private func weatherPanel(size: CGSize) -> some View {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(
LinearGradient(
colors: [Color.blue.opacity(0.78), Color.blue.opacity(0.52)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: size.width, height: size.height)
.overlay(alignment: .leading) {
VStack(alignment: .leading, spacing: 4) {
Label("Weather", systemImage: "cloud.sun.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color.white)
Text("Inline snapshot preview")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Color.white.opacity(0.72))
}
.padding(.horizontal, 16)
}
.shadow(color: Color.blue.opacity(0.22), radius: 10, x: 0, y: 8)
}
private func musicPanel(size: CGSize) -> some View {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(
LinearGradient(
colors: [Color.purple.opacity(0.68), Color.pink.opacity(0.5)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: size.width, height: size.height)
.overlay(alignment: .leading) {
VStack(alignment: .leading, spacing: 6) {
Label("Media", systemImage: "play.square.stack")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Color.white)
Text("Lock screen panel preview")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Color.white.opacity(0.72))
}
.padding(.horizontal, 18)
}
.shadow(color: Color.purple.opacity(0.24), radius: 12, x: 0, y: 9)
}
private func timerPanel(size: CGSize) -> some View {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(
LinearGradient(
colors: [Color.orange.opacity(0.75), Color.purple.opacity(0.55)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: size.width, height: size.height)
.overlay {
VStack(spacing: 6) {
Text("Timer")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
Text("00:05:00")
.font(.system(size: 18, weight: .bold, design: .monospaced))
.foregroundStyle(.white)
}
}
.shadow(color: Color.orange.opacity(0.3), radius: 12, x: 0, y: 8)
}
private func weatherDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if !isWeatherDragging {
isWeatherDragging = true
weatherStartOffset = weatherOffset
}
let proposed = weatherStartOffset - Double(value.translation.height)
weatherOffset = clampedOffset(
proposed,
baseCenterY: baseY,
panelHeight: panelSize.height,
screenRect: screenRect
)
}
.onEnded { _ in
isWeatherDragging = false
}
}
private func musicDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if !isMusicDragging {
isMusicDragging = true
musicStartOffset = musicOffset
}
let proposed = musicStartOffset - Double(value.translation.height)
musicOffset = clampedOffset(
proposed,
baseCenterY: baseY,
panelHeight: panelSize.height,
screenRect: screenRect
)
}
.onEnded { _ in
isMusicDragging = false
}
}
private func timerDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if !isTimerDragging {
isTimerDragging = true
timerStartOffset = timerOffset
}
let proposed = timerStartOffset - Double(value.translation.height)
timerOffset = clampedOffset(
proposed,
baseCenterY: baseY,
panelHeight: panelSize.height,
screenRect: screenRect
)
}
.onEnded { _ in
isTimerDragging = false
}
}
private func clampedOffset(
_ proposed: Double,
baseCenterY: CGFloat,
panelHeight: CGFloat,
screenRect: CGRect
) -> Double {
let halfHeight = panelHeight / 2
let minCenterY = screenRect.minY + halfHeight
let maxCenterY = screenRect.maxY - halfHeight
let proposedCenter = baseCenterY - CGFloat(proposed)
let clampedCenter = min(max(proposedCenter, minCenterY), maxCenterY)
let derivedOffset = Double(baseCenterY - clampedCenter)
return min(max(derivedOffset, offsetRange.lowerBound), offsetRange.upperBound)
}
}
private func copyLatestCrashReport() {
let crashReportsPath = NSString(string: "~/Library/Logs/DiagnosticReports").expandingTildeInPath
let fileManager = FileManager.default
do {
let files = try fileManager.contentsOfDirectory(atPath: crashReportsPath)
let crashFiles = files.filter { $0.contains("DynamicIsland") && $0.hasSuffix(".crash") }
guard let latestCrash = crashFiles.sorted(by: >).first else {
let alert = NSAlert()
alert.messageText = "No Crash Reports Found"
alert.informativeText = "No crash reports found for DynamicIsland"
alert.alertStyle = .informational
alert.runModal()
return
}
let crashPath = (crashReportsPath as NSString).appendingPathComponent(latestCrash)
let crashContent = try String(contentsOfFile: crashPath, encoding: .utf8)
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(crashContent, forType: .string)
let alert = NSAlert()
alert.messageText = "Crash Report Copied"
alert.informativeText = "Crash report '\(latestCrash)' has been copied to clipboard"
alert.alertStyle = .informational
alert.runModal()
} catch {
let alert = NSAlert()
alert.messageText = "Error"
alert.informativeText = "Failed to read crash reports: \(error.localizedDescription)"
alert.alertStyle = .warning
alert.runModal()
}
}
struct Shortcuts: View {
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.enableClipboardManager) var enableClipboardManager
@Default(.enableShortcuts) var enableShortcuts
@Default(.enableStatsFeature) var enableStatsFeature
@Default(.enableColorPickerFeature) var enableColorPickerFeature
var body: some View {
Form {
Section {
Defaults.Toggle("Enable global keyboard shortcuts", key: .enableShortcuts)
} header: {
Text("General")
} footer: {
Text("When disabled, all keyboard shortcuts will be inactive. You can still use the UI controls.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
if enableShortcuts {
Section {
KeyboardShortcuts.Recorder("Toggle Sneak Peek:", name: .toggleSneakPeek)
.disabled(!enableShortcuts)
} header: {
Text("Media")
} footer: {
Text("Sneak Peek shows the media title and artist under the notch for a few seconds.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
Section {
KeyboardShortcuts.Recorder("Toggle Notch Open:", name: .toggleNotchOpen)
.disabled(!enableShortcuts)
} header: {
Text("Navigation")
} footer: {
Text("Toggle the Dynamic Island open or closed from anywhere.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
Section {
HStack {
VStack(alignment: .leading) {
KeyboardShortcuts.Recorder("Start Demo Timer:", name: .startDemoTimer)
.disabled(!enableShortcuts || !enableTimerFeature)
if !enableTimerFeature {
Text("Timer feature is disabled")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 2)
}
}
Spacer()
}
} header: {
Text("Timer")
} footer: {
Text("Starts a 5-minute demo timer to test the timer live activity feature. Only works when timer feature is enabled.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
Section {
HStack {
VStack(alignment: .leading) {
KeyboardShortcuts.Recorder("Clipboard History:", name: .clipboardHistoryPanel)
.disabled(!enableShortcuts || !enableClipboardManager)
if !enableClipboardManager {
Text("Clipboard feature is disabled")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 2)
}
}
Spacer()
}
} header: {
Text("Clipboard")
} footer: {
Text("Opens the clipboard history panel. Default is Cmd+Shift+V (similar to Windows+V on PC). Only works when clipboard feature is enabled.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
Section {
HStack {
VStack(alignment: .leading) {
KeyboardShortcuts.Recorder("Screen Assistant:", name: .screenAssistantPanel)
.disabled(!enableShortcuts || !Defaults[.enableScreenAssistant])
if !Defaults[.enableScreenAssistant] {
Text("Screen Assistant feature is disabled")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 2)
}
}
Spacer()
}
} header: {
Text("AI Assistant")
} footer: {
Text("Opens the AI assistant panel for file analysis and conversation. Default is Cmd+Shift+A. Only works when screen assistant feature is enabled.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
Section {
HStack {
VStack(alignment: .leading) {
KeyboardShortcuts.Recorder("Color Picker Panel:", name: .colorPickerPanel)
.disabled(!enableShortcuts || !enableColorPickerFeature)
if !enableColorPickerFeature {
Text("Color Picker feature is disabled")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, 2)
}
}
Spacer()
}
} header: {
Text("Color Picker")
} footer: {
Text("Opens the color picker panel for screen color capture. Default is Cmd+Shift+P. Only works when color picker feature is enabled.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
} else {
Section {
VStack(alignment: .leading, spacing: 8) {
Text("Keyboard shortcuts are disabled")
.font(.headline)
.foregroundStyle(.secondary)
Text("Enable global keyboard shortcuts above to customize your shortcuts.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 8)
}
}
}
.navigationTitle("Shortcuts")
}
}
func proFeatureBadge() -> some View {
Text("Upgrade to Pro")
.foregroundStyle(Color(red: 0.545, green: 0.196, blue: 0.98))
.font(.footnote.bold())
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(RoundedRectangle(cornerRadius: 4).stroke(Color(red: 0.545, green: 0.196, blue: 0.98), lineWidth: 1))
}
func comingSoonTag() -> some View {
Text("Coming soon")
.foregroundStyle(.secondary)
.font(.footnote.bold())
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color(nsColor: .secondarySystemFill))
.clipShape(.capsule)
}
func customBadge(text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.font(.footnote.bold())
.padding(.vertical, 3)
.padding(.horizontal, 6)
.background(Color(nsColor: .secondarySystemFill))
.clipShape(.capsule)
}
func warningBadge(_ text: String, _ description: String) -> some View {
Section {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 22))
.foregroundStyle(.yellow)
VStack(alignment: .leading) {
Text(text)
.font(.headline)
Text(description)
.foregroundStyle(.secondary)
}
Spacer()
}
}
}
struct TimerSettings: View {
@ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature
@Default(.timerPresets) private var timerPresets
@Default(.timerIconColorMode) private var colorMode
@Default(.timerSolidColor) private var solidColor
@Default(.timerShowsCountdown) private var showsCountdown
@Default(.timerShowsLabel) private var showsLabel
@Default(.timerShowsProgress) private var showsProgress
@Default(.timerProgressStyle) private var progressStyle
@Default(.timerControlWindowEnabled) private var controlWindowEnabled
@Default(.mirrorSystemTimer) private var mirrorSystemTimer
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600
@State private var customHours: Int = 0
@State private var customMinutes: Int = 10
@State private var customSeconds: Int = 0
@State private var showingResetConfirmation = false
var body: some View {
Form {
Section {
Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
if enableTimerFeature {
Toggle("Enable timer live activity", isOn: $coordinator.timerLiveActivityEnabled)
.animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
Defaults.Toggle("Mirror macOS Clock timers", key: .mirrorSystemTimer)
.help("Shows the system Clock timer in the notch when available. Requires Accessibility permission to read the status item.")
}
} header: {
Text("Timer Feature")
} footer: {
Text("Control timer availability, live activity behaviour, and whether the app mirrors timers started from the macOS Clock app.")
}
if enableTimerFeature {
Group {
Section {
Defaults.Toggle("Show lock screen timer widget", key: .enableLockScreenTimerWidget)
Defaults.Toggle("Enable timer blur", key: .lockScreenTimerWidgetUsesBlur)
.disabled(!enableLockScreenTimerWidget)
} header: {
Text("Lock Screen Integration")
} footer: {
Text("Mirrors the toggle found under Lock Screen settings so timer-specific workflows can enable or disable the widget without switching tabs.")
}
Section {
VStack(alignment: .leading, spacing: 12) {
Text("Default Custom Timer")
.font(.headline)
TimerDurationStepperRow(title: "Hours", value: $customHours, range: 0...23)
TimerDurationStepperRow(title: "Minutes", value: $customMinutes, range: 0...59)
TimerDurationStepperRow(title: "Seconds", value: $customSeconds, range: 0...59)
HStack {
Text("Current default:")
.foregroundStyle(.secondary)
Text(customDurationDisplay)
.font(.system(.body, design: .monospaced))
.fontWeight(.medium)
Spacer()
}
}
.padding(.vertical, 4)
.onChange(of: customHours) { _, _ in updateCustomDuration() }
.onChange(of: customMinutes) { _, _ in updateCustomDuration() }
.onChange(of: customSeconds) { _, _ in updateCustomDuration() }
} header: {
Text("Custom Timer")
} footer: {
Text("This duration powers the \"Custom\" option inside the timer popover for quick access.")
}
Section {
Picker("Timer tint", selection: $colorMode) {
ForEach(TimerIconColorMode.allCases) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.segmented)
if colorMode == .solid {
ColorPicker("Solid colour", selection: $solidColor, supportsOpacity: false)
}
Toggle("Show timer name", isOn: $showsLabel)
Toggle("Show countdown", isOn: $showsCountdown)
Toggle("Show progress", isOn: $showsProgress)
Toggle("Show floating pause/stop controls", isOn: $controlWindowEnabled)
.disabled(showsLabel)
.help("These controls sit beside the notch while a timer runs. They require the timer name to stay hidden for spacing.")
Picker("Progress style", selection: $progressStyle) {
ForEach(TimerProgressStyle.allCases) { style in
Text(style.rawValue).tag(style)
}
}
.pickerStyle(.segmented)
.disabled(!showsProgress)
} header: {
Text("Appearance")
} footer: {
Text("Configure how the timer looks inside the closed notch. Progress can render as a ring around the icon or as horizontal bars.")
}
Section {
if timerPresets.isEmpty {
Text("No presets configured. Add a preset to make it appear in the timer popover.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.padding(.vertical, 4)
} else {
ForEach(timerPresets.indices, id: \.self) { index in
TimerPresetEditorRow(
preset: $timerPresets[index],
isFirst: index == timerPresets.startIndex,
isLast: index == timerPresets.index(before: timerPresets.endIndex),
moveUp: { movePresetUp(index) },
moveDown: { movePresetDown(index) },
remove: { removePreset(index) }
)
}
}
HStack {
Button(action: addPreset) {
Label("Add Preset", systemImage: "plus")
}
.buttonStyle(.bordered)
Spacer()
Button(role: .destructive, action: { showingResetConfirmation = true }) {
Label("Restore Defaults", systemImage: "arrow.counterclockwise")
}
.buttonStyle(.bordered)
.confirmationDialog("Restore default timer presets?", isPresented: $showingResetConfirmation, titleVisibility: .visible) {
Button("Restore", role: .destructive, action: resetPresets)
}
}
} header: {
Text("Timer Presets")
} footer: {
Text("Presets show up inside the timer popover with the configured name, duration, and accent colour. Reorder them to change the display order.")
}
Section {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Timer Sound")
.font(.system(size: 16, weight: .medium))
Spacer()
Button("Choose File", action: selectCustomTimerSound)
.buttonStyle(.bordered)
}
if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") {
Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Default: dynamic.m4a")
.font(.caption)
.foregroundColor(.secondary)
}
Button("Reset to Default") {
UserDefaults.standard.removeObject(forKey: "customTimerSoundPath")
}
.buttonStyle(.bordered)
.disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil)
}
} header: {
Text("Timer Sound")
} footer: {
Text("Select a custom sound to play when a timer ends. Supported formats include MP3, M4A, WAV, and AIFF.")
}
}
.onAppear {
if showsLabel {
controlWindowEnabled = false
}
}
.onChange(of: showsLabel) { _, show in
if show {
controlWindowEnabled = false
}
}
}
}
.navigationTitle("Timer")
.onAppear { syncCustomDuration() }
.onChange(of: customTimerDuration) { _, newValue in syncCustomDuration(newValue) }
}
private var customDurationDisplay: String {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = customTimerDuration >= 3600 ? [.hour, .minute, .second] : [.minute, .second]
formatter.zeroFormattingBehavior = [.pad]
return formatter.string(from: customTimerDuration) ?? "0:00"
}
private func syncCustomDuration(_ value: Double? = nil) {
let baseValue = value ?? customTimerDuration
let components = TimerPreset.components(for: baseValue)
customHours = components.hours
customMinutes = components.minutes
customSeconds = components.seconds
}
private func updateCustomDuration() {
let duration = TimeInterval(customHours * 3600 + customMinutes * 60 + customSeconds)
customTimerDuration = duration
}
private func addPreset() {
let nextIndex = timerPresets.count + 1
let defaultColor = Defaults[.accentColor]
let newPreset = TimerPreset(name: "Preset \(nextIndex)", duration: 5 * 60, color: defaultColor)
withAnimation(.smooth) {
timerPresets.append(newPreset)
}
}
private func movePresetUp(_ index: Int) {
guard index > timerPresets.startIndex else { return }
withAnimation(.smooth) {
timerPresets.swapAt(index, index - 1)
}
}
private func movePresetDown(_ index: Int) {
guard index < timerPresets.index(before: timerPresets.endIndex) else { return }
withAnimation(.smooth) {
timerPresets.swapAt(index, index + 1)
}
}
private func removePreset(_ index: Int) {
guard timerPresets.indices.contains(index) else { return }
withAnimation(.smooth) {
timerPresets.remove(at: index)
}
}
private func resetPresets() {
withAnimation(.smooth) {
timerPresets = TimerPreset.defaultPresets
}
}
private func selectCustomTimerSound() {
let panel = NSOpenPanel()
panel.title = "Select Timer Sound"
panel.allowedContentTypes = [.audio]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
if panel.runModal() == .OK {
if let url = panel.url {
UserDefaults.standard.set(url.path, forKey: "customTimerSoundPath")
}
}
}
}
private struct TimerDurationStepperRow: View {
let title: String
@Binding var value: Int
let range: ClosedRange<Int>
var body: some View {
Stepper(value: $value, in: range) {
HStack {
Text(title)
Spacer()
Text("\(value)")
.font(.system(size: 12, weight: .semibold, design: .monospaced))
}
}
}
}
private struct TimerPresetEditorRow: View {
@Binding var preset: TimerPreset
let isFirst: Bool
let isLast: Bool
let moveUp: () -> Void
let moveDown: () -> Void
let remove: () -> Void
private var components: TimerPreset.DurationComponents {
TimerPreset.components(for: preset.duration)
}
private var hoursBinding: Binding<Int> {
Binding(
get: { components.hours },
set: { updateDuration(hours: $0) }
)
}
private var minutesBinding: Binding<Int> {
Binding(
get: { components.minutes },
set: { updateDuration(minutes: $0) }
)
}
private var secondsBinding: Binding<Int> {
Binding(
get: { components.seconds },
set: { updateDuration(seconds: $0) }
)
}
private var colorBinding: Binding<Color> {
Binding(
get: { preset.color },
set: { preset.updateColor($0) }
)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 10) {
Circle()
.fill(preset.color.gradient)
.frame(width: 20, height: 20)
.overlay(
Circle()
.stroke(Color.white.opacity(0.3), lineWidth: 1)
)
TextField("Preset name", text: $preset.name)
.textFieldStyle(.roundedBorder)
Spacer()
Text(preset.formattedDuration)
.font(.system(size: 12, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
}
HStack(spacing: 16) {
TimerPresetComponentControl(title: "Hours", value: hoursBinding, range: 0...23)
TimerPresetComponentControl(title: "Minutes", value: minutesBinding, range: 0...59)
TimerPresetComponentControl(title: "Seconds", value: secondsBinding, range: 0...59)
}
ColorPicker("Accent colour", selection: colorBinding, supportsOpacity: false)
.frame(maxWidth: 240, alignment: .leading)
HStack(spacing: 12) {
Button(action: moveUp) {
Label("Move Up", systemImage: "chevron.up")
}
.buttonStyle(.bordered)
.disabled(isFirst)
Button(action: moveDown) {
Label("Move Down", systemImage: "chevron.down")
}
.buttonStyle(.bordered)
.disabled(isLast)
Spacer()
Button(role: .destructive, action: remove) {
Label("Delete", systemImage: "trash")
}
.buttonStyle(.bordered)
}
.font(.system(size: 12, weight: .medium))
}
.padding(.vertical, 6)
}
private func updateDuration(hours: Int? = nil, minutes: Int? = nil, seconds: Int? = nil) {
var values = components
if let hours { values.hours = hours }
if let minutes { values.minutes = minutes }
if let seconds { values.seconds = seconds }
preset.duration = TimerPreset.duration(from: values)
}
}
private struct TimerPresetComponentControl: View {
let title: String
@Binding var value: Int
let range: ClosedRange<Int>
var body: some View {
Stepper(value: $value, in: range) {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text("\(value)")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
}
}
.frame(width: 110, alignment: .leading)
}
}
struct StatsSettings: View {
@ObservedObject var statsManager = StatsManager.shared
@Default(.enableStatsFeature) var enableStatsFeature
@Default(.statsStopWhenNotchCloses) var statsStopWhenNotchCloses
@Default(.statsUpdateInterval) var statsUpdateInterval
@Default(.showCpuGraph) var showCpuGraph
@Default(.showMemoryGraph) var showMemoryGraph
@Default(.showGpuGraph) var showGpuGraph
@Default(.showNetworkGraph) var showNetworkGraph
@Default(.showDiskGraph) var showDiskGraph
var enabledGraphsCount: Int {
[showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph].filter { $0 }.count
}
private var formattedUpdateInterval: String {
let seconds = Int(statsUpdateInterval.rounded())
if seconds >= 60 {
return "60 s (1 min)"
} else if seconds == 1 {
return "1 s"
} else {
return "\(seconds) s"
}
}
private var shouldShowStatsBatteryWarning: Bool {
!statsStopWhenNotchCloses && statsUpdateInterval <= 5
}
var body: some View {
Form {
Section {
Defaults.Toggle("Enable system stats monitoring", key: .enableStatsFeature)
.onChange(of: enableStatsFeature) { _, newValue in
if !newValue {
statsManager.stopMonitoring()
}
// Note: Smart monitoring will handle starting when switching to stats tab
}
} header: {
Text("General")
} footer: {
Text("When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
if enableStatsFeature {
Section {
Defaults.Toggle("Stop monitoring after closing the notch", key: .statsStopWhenNotchCloses)
.help("When enabled, stats monitoring stops a few seconds after the notch closes.")
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Update interval")
Spacer()
Text(formattedUpdateInterval)
.foregroundStyle(.secondary)
}
Slider(value: $statsUpdateInterval, in: 1...60, step: 1)
.accessibilityLabel("Stats update interval")
Text("Controls how often system metrics refresh while monitoring is active.")
.font(.caption)
.foregroundStyle(.secondary)
}
if shouldShowStatsBatteryWarning {
Label {
Text("High-frequency updates without a timeout can increase battery usage.")
} icon: {
Image(systemName: "exclamationmark.triangle.fill")
}
.font(.caption)
.foregroundStyle(.orange)
.padding(.top, 4)
}
} header: {
Text("Monitoring Behavior")
} footer: {
Text("Sampling can continue while the notch is closed when the timeout is disabled.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
Section {
Defaults.Toggle("CPU Usage", key: .showCpuGraph)
Defaults.Toggle("Memory Usage", key: .showMemoryGraph)
Defaults.Toggle("GPU Usage", key: .showGpuGraph)
Defaults.Toggle("Network Activity", key: .showNetworkGraph)
Defaults.Toggle("Disk I/O", key: .showDiskGraph)
} header: {
Text("Graph Visibility")
} footer: {
if enabledGraphsCount >= 4 {
Text("With \(enabledGraphsCount) graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
} else {
Text("Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
}
Section {
HStack {
Text("Monitoring Status")
Spacer()
HStack(spacing: 4) {
Circle()
.fill(statsManager.isMonitoring ? .green : .red)
.frame(width: 8, height: 8)
Text(statsManager.isMonitoring ? "Active" : "Stopped")
.foregroundStyle(.secondary)
}
}
if statsManager.isMonitoring {
if showCpuGraph {
HStack {
Text("CPU Usage")
Spacer()
Text(statsManager.cpuUsageString)
.foregroundStyle(.secondary)
}
}
if showMemoryGraph {
HStack {
Text("Memory Usage")
Spacer()
Text(statsManager.memoryUsageString)
.foregroundStyle(.secondary)
}
}
if showGpuGraph {
HStack {
Text("GPU Usage")
Spacer()
Text(statsManager.gpuUsageString)
.foregroundStyle(.secondary)
}
}
if showNetworkGraph {
HStack {
Text("Network Download")
Spacer()
Text(String(format: "%.1f MB/s", statsManager.networkDownload))
.foregroundStyle(.secondary)
}
HStack {
Text("Network Upload")
Spacer()
Text(String(format: "%.1f MB/s", statsManager.networkUpload))
.foregroundStyle(.secondary)
}
}
if showDiskGraph {
HStack {
Text("Disk Read")
Spacer()
Text(String(format: "%.1f MB/s", statsManager.diskRead))
.foregroundStyle(.secondary)
}
HStack {
Text("Disk Write")
Spacer()
Text(String(format: "%.1f MB/s", statsManager.diskWrite))
.foregroundStyle(.secondary)
}
}
HStack {
Text("Last Updated")
Spacer()
Text(statsManager.lastUpdated, style: .relative)
.foregroundStyle(.secondary)
}
}
} header: {
Text("Live Performance Data")
}
Section {
HStack {
Button(statsManager.isMonitoring ? "Stop Monitoring" : "Start Monitoring") {
if statsManager.isMonitoring {
statsManager.stopMonitoring()
} else {
statsManager.startMonitoring()
}
}
.buttonStyle(.borderedProminent)
.foregroundColor(statsManager.isMonitoring ? .red : .blue)
Spacer()
Button("Clear Data") {
statsManager.clearHistory()
}
.buttonStyle(.bordered)
.disabled(statsManager.isMonitoring)
}
} header: {
Text("Controls")
}
}
}
.navigationTitle("Stats")
}
}
struct ClipboardSettings: View {
@ObservedObject var clipboardManager = ClipboardManager.shared
@Default(.enableClipboardManager) var enableClipboardManager
@Default(.clipboardHistorySize) var clipboardHistorySize
@Default(.showClipboardIcon) var showClipboardIcon
@Default(.clipboardDisplayMode) var clipboardDisplayMode
var body: some View {
Form {
Section {
Defaults.Toggle("Enable Clipboard Manager", key: .enableClipboardManager)
.onChange(of: enableClipboardManager) { _, enabled in
if enabled {
clipboardManager.startMonitoring()
} else {
clipboardManager.stopMonitoring()
}
}
} header: {
Text("Clipboard Manager")
} footer: {
Text("Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history.")
}
if enableClipboardManager {
Section {
Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon)
HStack {
Text("Display Mode")
Spacer()
Picker("Display Mode", selection: $clipboardDisplayMode) {
ForEach(ClipboardDisplayMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.menu)
.frame(width: 100)
}
HStack {
Text("History Size")
Spacer()
Picker("History Size", selection: $clipboardHistorySize) {
Text("3 items").tag(3)
Text("5 items").tag(5)
Text("7 items").tag(7)
Text("10 items").tag(10)
}
.pickerStyle(.menu)
.frame(width: 100)
}
HStack {
Text("Current Items")
Spacer()
Text("\(clipboardManager.clipboardHistory.count)")
.foregroundColor(.secondary)
}
HStack {
Text("Pinned Items")
Spacer()
Text("\(clipboardManager.pinnedItems.count)")
.foregroundColor(.secondary)
}
HStack {
Text("Monitoring Status")
Spacer()
Text(clipboardManager.isMonitoring ? "Active" : "Stopped")
.foregroundColor(clipboardManager.isMonitoring ? .green : .secondary)
}
} header: {
Text("Settings")
} footer: {
switch clipboardDisplayMode {
case .popover:
Text("Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch.")
case .panel:
Text("Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button.")
}
}
Section {
Button("Clear Clipboard History") {
clipboardManager.clearHistory()
}
.foregroundColor(.red)
.disabled(clipboardManager.clipboardHistory.isEmpty)
Button("Clear Pinned Items") {
clipboardManager.pinnedItems.removeAll()
clipboardManager.savePinnedItemsToDefaults()
}
.foregroundColor(.red)
.disabled(clipboardManager.pinnedItems.isEmpty)
} header: {
Text("Actions")
} footer: {
Text("Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent.")
}
if !clipboardManager.clipboardHistory.isEmpty {
Section {
ForEach(clipboardManager.clipboardHistory) { item in
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: item.type.icon)
.foregroundColor(.blue)
.frame(width: 16)
Text(item.type.displayName)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(timeAgoString(from: item.timestamp))
.font(.caption2)
.foregroundColor(.secondary)
}
Text(item.preview)
.font(.system(.body, design: .monospaced))
.lineLimit(2)
}
.padding(.vertical, 2)
}
} header: {
Text("Current History")
}
}
}
}
.formStyle(.grouped)
.navigationTitle("Clipboard")
.onAppear {
if enableClipboardManager && !clipboardManager.isMonitoring {
clipboardManager.startMonitoring()
}
}
}
private func timeAgoString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 {
return "Just now"
} else if interval < 3600 {
let minutes = Int(interval / 60)
return "\(minutes)m ago"
} else if interval < 86400 {
let hours = Int(interval / 3600)
return "\(hours)h ago"
} else {
let days = Int(interval / 86400)
return "\(days)d ago"
}
}
}
struct ScreenAssistantSettings: View {
@ObservedObject var screenAssistantManager = ScreenAssistantManager.shared
@Default(.enableScreenAssistant) var enableScreenAssistant
@Default(.screenAssistantDisplayMode) var screenAssistantDisplayMode
@Default(.geminiApiKey) var geminiApiKey
@State private var apiKeyText = ""
@State private var showingApiKey = false
var body: some View {
Form {
Section {
Defaults.Toggle("Enable Screen Assistant", key: .enableScreenAssistant)
} header: {
Text("AI Assistant")
} footer: {
Text("AI-powered assistant that can analyze files, images, and provide conversational help. Use Cmd+Shift+A to quickly access the assistant.")
}
if enableScreenAssistant {
Section {
HStack {
Text("Gemini API Key")
Spacer()
if geminiApiKey.isEmpty {
Text("Not Set")
.foregroundColor(.red)
} else {
Text("••••••••")
.foregroundColor(.green)
}
Button(showingApiKey ? "Hide" : (geminiApiKey.isEmpty ? "Set" : "Change")) {
if showingApiKey {
showingApiKey = false
if !apiKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Defaults[.geminiApiKey] = apiKeyText
}
apiKeyText = ""
} else {
showingApiKey = true
apiKeyText = geminiApiKey
}
}
}
if showingApiKey {
VStack(alignment: .leading, spacing: 8) {
SecureField("Enter your Gemini API Key", text: $apiKeyText)
.textFieldStyle(.roundedBorder)
Text("Get your free API key from Google AI Studio")
.font(.caption)
.foregroundColor(.secondary)
HStack {
Button("Open Google AI Studio") {
NSWorkspace.shared.open(URL(string: "https://aistudio.google.com/app/apikey")!)
}
.buttonStyle(.link)
Spacer()
Button("Save") {
Defaults[.geminiApiKey] = apiKeyText
showingApiKey = false
apiKeyText = ""
}
.disabled(apiKeyText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
HStack {
Text("Display Mode")
Spacer()
Picker("Display Mode", selection: $screenAssistantDisplayMode) {
ForEach(ScreenAssistantDisplayMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.menu)
.frame(width: 100)
}
HStack {
Text("Attached Files")
Spacer()
Text("\(screenAssistantManager.attachedFiles.count)")
.foregroundColor(.secondary)
}
HStack {
Text("Recording Status")
Spacer()
Text(screenAssistantManager.isRecording ? "Recording" : "Ready")
.foregroundColor(screenAssistantManager.isRecording ? .red : .secondary)
}
} header: {
Text("Configuration")
} footer: {
switch screenAssistantDisplayMode {
case .popover:
Text("Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch.")
case .panel:
Text("Panel mode shows the assistant in a floating window near the notch. Popover mode shows the assistant as a dropdown attached to the AI button.")
}
}
Section {
Button("Clear All Files") {
screenAssistantManager.clearAllFiles()
}
.foregroundColor(.red)
.disabled(screenAssistantManager.attachedFiles.isEmpty)
} header: {
Text("Actions")
} footer: {
Text("Clear all files removes all attached files and audio recordings. This action is permanent.")
}
if !screenAssistantManager.attachedFiles.isEmpty {
Section {
ForEach(screenAssistantManager.attachedFiles) { file in
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: file.type.iconName)
.foregroundColor(.blue)
.frame(width: 16)
Text(file.type.displayName)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(timeAgoString(from: file.timestamp))
.font(.caption2)
.foregroundColor(.secondary)
}
Text(file.name)
.font(.system(.body, design: .monospaced))
.lineLimit(2)
}
.padding(.vertical, 2)
}
} header: {
Text("Attached Files")
}
}
}
}
.formStyle(.grouped)
.navigationTitle("Screen Assistant")
}
private func timeAgoString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 {
return "Just now"
} else if interval < 3600 {
let minutes = Int(interval / 60)
return "\(minutes)m ago"
} else if interval < 86400 {
let hours = Int(interval / 3600)
return "\(hours)h ago"
} else {
let days = Int(interval / 86400)
return "\(days)d ago"
}
}
}
struct ColorPickerSettings: View {
@ObservedObject var colorPickerManager = ColorPickerManager.shared
@Default(.enableColorPickerFeature) var enableColorPickerFeature
@Default(.showColorFormats) var showColorFormats
@Default(.colorPickerDisplayMode) var colorPickerDisplayMode
@Default(.colorHistorySize) var colorHistorySize
@Default(.showColorPickerIcon) var showColorPickerIcon
var body: some View {
Form {
Section {
Defaults.Toggle("Enable Color Picker", key: .enableColorPickerFeature)
} header: {
Text("Color Picker")
} footer: {
Text("Enable screen color picking functionality. Use Cmd+Shift+P to quickly access the color picker.")
}
if enableColorPickerFeature {
Section {
Defaults.Toggle("Show Color Picker Icon", key: .showColorPickerIcon)
HStack {
Text("Display Mode")
Spacer()
Picker("Display Mode", selection: $colorPickerDisplayMode) {
ForEach(ColorPickerDisplayMode.allCases, id: \.self) { mode in
Text(mode.displayName).tag(mode)
}
}
.pickerStyle(.menu)
.frame(width: 100)
}
HStack {
Text("History Size")
Spacer()
Picker("History Size", selection: $colorHistorySize) {
Text("5 colors").tag(5)
Text("10 colors").tag(10)
Text("15 colors").tag(15)
Text("20 colors").tag(20)
}
.pickerStyle(.menu)
.frame(width: 100)
}
Defaults.Toggle("Show All Color Formats", key: .showColorFormats)
} header: {
Text("Settings")
} footer: {
switch colorPickerDisplayMode {
case .popover:
Text("Popover mode shows color picker as a dropdown attached to the color picker button. Panel mode shows color picker in a floating window.")
case .panel:
Text("Panel mode shows color picker in a floating window. Popover mode shows color picker as a dropdown attached to the color picker button.")
}
}
Section {
HStack {
Text("Color History")
Spacer()
Text("\(colorPickerManager.colorHistory.count)")
.foregroundColor(.secondary)
}
HStack {
Text("Picking Status")
Spacer()
Text(colorPickerManager.isPickingColor ? "Active" : "Ready")
.foregroundColor(colorPickerManager.isPickingColor ? .green : .secondary)
}
Button("Show Color Picker Panel") {
ColorPickerPanelManager.shared.showColorPickerPanel()
}
.disabled(!enableColorPickerFeature)
} header: {
Text("Status & Actions")
}
Section {
Button("Clear Color History") {
colorPickerManager.clearHistory()
}
.foregroundColor(.red)
.disabled(colorPickerManager.colorHistory.isEmpty)
Button("Start Color Picking") {
colorPickerManager.startColorPicking()
}
.disabled(!enableColorPickerFeature || colorPickerManager.isPickingColor)
} header: {
Text("Quick Actions")
} footer: {
Text("Clear color history removes all picked colors. Start color picking begins screen color capture mode.")
}
}
}
.navigationTitle("Color Picker")
}
}
#Preview {
HUD()
}