diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index b5f8302..5c65520 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -450,8 +450,25 @@ class AppDelegate: NSObject, NSApplicationDelegate { ClipboardManager.shared.startMonitoring() } - // Always use panel mode - ClipboardPanelManager.shared.toggleClipboardPanel() + // Handle keyboard shortcut based on display mode + switch Defaults[.clipboardDisplayMode] { + case .panel: + ClipboardPanelManager.shared.toggleClipboardPanel() + case .popover: + // For popover mode, first ensure notch is open, then toggle popover + + // If notch is closed, open it first + if self.vm.notchState == .closed { + self.vm.open() + // Wait a moment for the notch to open, then show popover + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil) + } + } else { + // Notch is already open, toggle popover immediately + NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil) + } + } } KeyboardShortcuts.onKeyDown(for: .statsPanel) { [weak self] in diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 61eae25..44fd617 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -139,9 +139,6 @@ }, "Appearance" : { - }, - "Auto mode intelligently chooses between popover (desktop) and window (fullscreen apps) for the best experience. You can override this with manual selection." : { - }, "Automatically check for updates" : { "localizations" : { @@ -323,6 +320,9 @@ }, "Copy something to get started" : { + }, + "Copy something to start" : { + }, "CPU Usage" : { @@ -394,6 +394,9 @@ }, "Disk Write" : { + }, + "Display Mode" : { + }, "Download" : { @@ -810,6 +813,9 @@ }, "No Graphs Enabled" : { + }, + "No results" : { + }, "No results found" : { @@ -869,6 +875,9 @@ }, "Opens the detailed system performance monitor panel. Default is Cmd+Shift+S. Only works when stats feature is enabled." : { + }, + "Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : { + }, "Pause" : { @@ -888,6 +897,9 @@ }, "Pin items from history to save them here" : { + }, + "Pin items to add favorites" : { + }, "Pin items to add them to favorites" : { @@ -897,6 +909,9 @@ }, "Plugged In" : { + }, + "Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch." : { + }, "Progress bar" : { "localizations" : { @@ -965,6 +980,9 @@ }, "Search clipboard..." : { + }, + "Search..." : { + }, "seconds" : { "extractionState" : "stale", @@ -1185,6 +1203,9 @@ }, "Try adjusting your search terms" : { + }, + "Try different search terms" : { + }, "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" : { diff --git a/DynamicIsland/components/Clipboard/ClipboardPopover.swift b/DynamicIsland/components/Clipboard/ClipboardPopover.swift new file mode 100644 index 0000000..6c60ae0 --- /dev/null +++ b/DynamicIsland/components/Clipboard/ClipboardPopover.swift @@ -0,0 +1,308 @@ +// +// ClipboardPopover.swift +// DynamicIsland +// +// Created by GitHub Copilot on 13/08/25. +// + +import SwiftUI +import Defaults + +struct ClipboardPopover: View { + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var selectedTab: ClipboardTab = .history + @State private var searchText = "" + @State private var hoveredItemId: UUID? + + var filteredItems: [ClipboardItem] { + let allItems = selectedTab == .history ? clipboardManager.regularHistory : clipboardManager.pinnedItems + + if searchText.isEmpty { + return allItems + } else { + return allItems.filter { item in + item.preview.localizedCaseInsensitiveContains(searchText) || + item.type.displayName.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + VStack(spacing: 0) { + // Header with tabs + ClipboardPopoverHeader( + selectedTab: $selectedTab, + searchText: $searchText + ) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Content + if filteredItems.isEmpty { + ClipboardPopoverEmptyState( + hasSearch: !searchText.isEmpty, + isHistoryTab: selectedTab == .history + ) + } else { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(filteredItems) { item in + ClipboardPopoverItemRow( + item: item, + isHovered: hoveredItemId == item.id, + isPinned: clipboardManager.pinnedItems.contains(where: { $0.id == item.id }) + ) { hoverId in + hoveredItemId = hoverId + } + } + } + .padding(.vertical, 8) + } + } + } + .frame(width: 280, height: 320) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(.regularMaterial) + .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4) + ) + } +} + +struct ClipboardPopoverHeader: View { + @Binding var selectedTab: ClipboardTab + @Binding var searchText: String + @ObservedObject var clipboardManager = ClipboardManager.shared + @FocusState private var isSearchFieldFocused: Bool + + var body: some View { + VStack(spacing: 8) { + // Title + HStack { + Image(systemName: "doc.on.clipboard") + .foregroundColor(.primary) + .font(.system(size: 14, weight: .medium)) + + Text("Clipboard") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + + // Clear button + Button(action: { + if selectedTab == .history { + clipboardManager.clearHistory() + } else { + clipboardManager.pinnedItems.removeAll() + clipboardManager.savePinnedItemsToDefaults() + } + }) { + Image(systemName: "trash") + .foregroundColor(.red) + .font(.system(size: 11)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(selectedTab == .history ? clipboardManager.clipboardHistory.isEmpty : clipboardManager.pinnedItems.isEmpty) + } + .padding(.horizontal, 14) + .padding(.top, 10) + + // Tab selector + HStack(spacing: 0) { + ForEach(ClipboardTab.allCases, id: \.self) { tab in + ClipboardTabButton(tab: tab, isSelected: selectedTab == tab) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedTab = tab + } + } + } + + Spacer() + } + .padding(.horizontal, 14) + + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 11)) + + TextField("Search...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 11)) + .focused($isSearchFieldFocused) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + .font(.system(size: 9)) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 5) + .fill(Color.gray.opacity(0.1)) + ) + .padding(.horizontal, 14) + } + .padding(.bottom, 6) + } +} + +struct ClipboardPopoverEmptyState: View { + let hasSearch: Bool + let isHistoryTab: Bool + + var body: some View { + VStack(spacing: 10) { + Image(systemName: hasSearch ? "magnifyingglass" : (isHistoryTab ? "doc.on.clipboard" : "heart.fill")) + .font(.system(size: 24)) + .foregroundColor(.secondary) + + if hasSearch { + Text("No results") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary) + + Text("Try different search terms") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } else { + Text(isHistoryTab ? "No clipboard history" : "No favorites") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.primary) + + Text(isHistoryTab ? "Copy something to start" : "Pin items to add favorites") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(20) + } +} + +struct ClipboardPopoverItemRow: View { + let item: ClipboardItem + let isHovered: Bool + let isPinned: Bool + let onHover: (UUID?) -> Void + @ObservedObject var clipboardManager = ClipboardManager.shared + + var body: some View { + HStack(alignment: .top, spacing: 10) { + // Type icon + Image(systemName: item.type.icon) + .font(.system(size: 12)) + .foregroundColor(.blue) + .frame(width: 16) + + // Content + VStack(alignment: .leading, spacing: 3) { + Text(item.preview) + .font(.system(size: 11)) + .foregroundColor(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + Text(item.type.displayName) + .font(.system(size: 9)) + .foregroundColor(.secondary) + + Spacer() + + Text(timeAgoString(from: item.timestamp)) + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + } + + Spacer() + + // Action buttons (shown on hover) + if isHovered { + HStack(spacing: 4) { + // Pin/Unpin button + Button(action: { + if isPinned { + clipboardManager.unpinItem(item) + } else { + clipboardManager.pinItem(item) + } + }) { + Image(systemName: isPinned ? "heart.fill" : "heart") + .font(.system(size: 9)) + .foregroundColor(isPinned ? .red : .gray) + } + .buttonStyle(PlainButtonStyle()) + + // Copy button + Button(action: { + clipboardManager.copyToClipboard(item) + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 9)) + .foregroundColor(.green) + } + .buttonStyle(PlainButtonStyle()) + + // Delete button + Button(action: { + if isPinned { + clipboardManager.unpinItem(item) + } else { + clipboardManager.deleteItem(item) + } + }) { + Image(systemName: "trash") + .font(.system(size: 9)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 4) + .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.15)) { + onHover(hovering ? item.id : nil) + } + } + .onTapGesture { + clipboardManager.copyToClipboard(item) + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return "now" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "\(minutes)m" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "\(hours)h" + } else { + let days = Int(interval / 86400) + return "\(days)d" + } + } +} + +#Preview { + ClipboardPopover() +} diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 0f005d6..9612a12 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -15,6 +15,7 @@ struct DynamicIslandHeader: View { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var clipboardManager = ClipboardManager.shared @StateObject var tvm = TrayDrop.shared + @State private var showClipboardPopover = false var body: some View { HStack(spacing: 0) { @@ -62,8 +63,13 @@ struct DynamicIslandHeader: View { if Defaults[.enableClipboardManager] && Defaults[.showClipboardIcon] { Button(action: { - // Always use panel mode - ClipboardPanelManager.shared.toggleClipboardPanel() + // Switch behavior based on display mode + switch Defaults[.clipboardDisplayMode] { + case .panel: + ClipboardPanelManager.shared.toggleClipboardPanel() + case .popover: + showClipboardPopover.toggle() + } }) { Capsule() .fill(.black) @@ -76,6 +82,9 @@ struct DynamicIslandHeader: View { } } .buttonStyle(PlainButtonStyle()) + .popover(isPresented: $showClipboardPopover, arrowEdge: .bottom) { + ClipboardPopover() + } .onAppear { if Defaults[.enableClipboardManager] && !clipboardManager.isMonitoring { clipboardManager.startMonitoring() @@ -125,7 +134,18 @@ struct DynamicIslandHeader: View { .onChange(of: coordinator.shouldToggleClipboardPopover) { _ in // Only toggle if clipboard is enabled if Defaults[.enableClipboardManager] { - ClipboardPanelManager.shared.toggleClipboardPanel() + switch Defaults[.clipboardDisplayMode] { + case .panel: + ClipboardPanelManager.shared.toggleClipboardPanel() + case .popover: + showClipboardPopover.toggle() + } + } + } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ToggleClipboardPopover"))) { _ in + // Handle keyboard shortcut for popover mode + if Defaults[.enableClipboardManager] && Defaults[.clipboardDisplayMode] == .popover { + showClipboardPopover.toggle() } } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 5df4dae..75084ff 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1630,6 +1630,7 @@ struct ClipboardSettings: View { @Default(.enableClipboardManager) var enableClipboardManager @Default(.clipboardHistorySize) var clipboardHistorySize @Default(.showClipboardIcon) var showClipboardIcon + @Default(.clipboardDisplayMode) var clipboardDisplayMode var body: some View { Form { @@ -1652,6 +1653,18 @@ struct ClipboardSettings: View { 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() @@ -1688,7 +1701,12 @@ struct ClipboardSettings: View { } header: { Text("Settings") } footer: { - Text("Auto mode intelligently chooses between popover (desktop) and window (fullscreen apps) for the best experience. You can override this with manual selection.") + 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 { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index a57b524..6fc0f36 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -31,17 +31,20 @@ enum CalendarSelectionState: Codable, Defaults.Serializable { } enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable { - case popover = "popover" // Always use floating panel + case popover = "popover" // Traditional popover attached to button + case panel = "panel" // Floating panel near notch var displayName: String { switch self { - case .popover: return "Panel" + case .popover: return "Popover" + case .panel: return "Panel" } } var description: String { switch self { - case .popover: return "Shows clipboard in a floating panel near the notch" + case .popover: return "Shows clipboard as a dropdown attached to the clipboard button" + case .panel: return "Shows clipboard in a floating panel near the notch" } } } @@ -187,7 +190,7 @@ extension Defaults.Keys { static let enableClipboardManager = Key("enableClipboardManager", default: true) static let clipboardHistorySize = Key("clipboardHistorySize", default: 3) static let showClipboardIcon = Key("showClipboardIcon", default: true) - static let clipboardDisplayMode = Key("clipboardDisplayMode", default: .popover) + static let clipboardDisplayMode = Key("clipboardDisplayMode", default: .panel) // MARK: Keyboard Shortcuts static let enableShortcuts = Key("enableShortcuts", default: true)