From 5321352184e6c38ff660a512cfc26385632b1b38 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 12 Aug 2025 14:52:03 +0530 Subject: [PATCH 1/3] Feature: Favourites --- DynamicIsland/DynamicIslandApp.swift | 63 +-- DynamicIsland/Localizable.xcstrings | 33 +- .../Clipboard/ClipboardWindow.swift | 363 ++++++++++++++++++ .../components/Settings/SettingsView.swift | 21 +- DynamicIsland/managers/ClipboardManager.swift | 82 ++++ .../managers/ClipboardWindowManager.swift | 104 +++++ DynamicIsland/models/Constants.swift | 1 + 7 files changed, 631 insertions(+), 36 deletions(-) create mode 100644 DynamicIsland/components/Clipboard/ClipboardWindow.swift create mode 100644 DynamicIsland/managers/ClipboardWindowManager.swift diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 613c2cd..5e09975 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -445,38 +445,45 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Only open clipboard if the feature is enabled guard Defaults[.enableClipboardManager] else { return } - // Find the appropriate view model based on mouse location - let mouseLocation = NSEvent.mouseLocation - var viewModel = self.vm - - if Defaults[.showOnAllDisplays] { - for screen in NSScreen.screens { - if screen.frame.contains(mouseLocation) { - if let screenViewModel = self.viewModels[screen] { - viewModel = screenViewModel - break - } - } - } - } - - // Open the notch if it's closed - if viewModel.notchState == .closed { - viewModel.open() - - // Wait a bit for the notch to open before toggling clipboard - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - self.coordinator.toggleClipboardPopover() - } - } else { - // If notch is already open, toggle immediately - self.coordinator.toggleClipboardPopover() - } - // Start clipboard monitoring if not already running if !ClipboardManager.shared.isMonitoring { ClipboardManager.shared.startMonitoring() } + + // Check if window mode is enabled + if Defaults[.clipboardWindowMode] { + // Use window interface + ClipboardWindowManager.shared.toggleClipboardWindow() + } else { + // Use traditional popover interface + // Find the appropriate view model based on mouse location + let mouseLocation = NSEvent.mouseLocation + var viewModel = self.vm + + if Defaults[.showOnAllDisplays] { + for screen in NSScreen.screens { + if screen.frame.contains(mouseLocation) { + if let screenViewModel = self.viewModels[screen] { + viewModel = screenViewModel + break + } + } + } + } + + // Open the notch if it's closed + if viewModel.notchState == .closed { + viewModel.open() + + // Wait a bit for the notch to open before toggling clipboard + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + self.coordinator.toggleClipboardPopover() + } + } else { + // If notch is already open, toggle immediately + self.coordinator.toggleClipboardPopover() + } + } } if !Defaults[.showOnAllDisplays] { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 8ec7b0f..4563a06 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -271,9 +271,15 @@ }, "Clear Clipboard History" : { + }, + "Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent." : { + }, "Clear Data" : { + }, + "Clear Pinned Items" : { + }, "Clipboard" : { @@ -783,9 +789,15 @@ }, "No extension installed" : { + }, + "No favorites" : { + }, "No Graphs Enabled" : { + }, + "No results found" : { + }, "Non-notch display height" : { @@ -852,6 +864,12 @@ } } } + }, + "Pin items from history to save them here" : { + + }, + "Pinned Items" : { + }, "Plugged In" : { @@ -920,6 +938,9 @@ }, "Running" : { + }, + "Search clipboard..." : { + }, "seconds" : { "extractionState" : "stale", @@ -1083,15 +1104,9 @@ }, "System features" : { - }, - "The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history." : { - }, "TheDynamicIsland" : { - }, - "This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header." : { - }, "Time to Full Charge: %lld min" : { @@ -1122,6 +1137,9 @@ }, "Transform your notch truly yours" : { + }, + "Try adjusting your 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" : { @@ -1178,6 +1196,9 @@ }, "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : { + }, + "Window Mode shows clipboard in a moveable window like Raycast with tabs for history and favorites. When disabled, clipboard appears in a popover attached to the Dynamic Island." : { + }, "With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : { diff --git a/DynamicIsland/components/Clipboard/ClipboardWindow.swift b/DynamicIsland/components/Clipboard/ClipboardWindow.swift new file mode 100644 index 0000000..b727067 --- /dev/null +++ b/DynamicIsland/components/Clipboard/ClipboardWindow.swift @@ -0,0 +1,363 @@ +// +// ClipboardWindow.swift +// DynamicIsland +// +// Created by GitHub Copilot on 12/08/25. +// + +import SwiftUI +import Defaults + +enum ClipboardTab: String, CaseIterable { + case history = "History" + case favorites = "Favorites" + + var icon: String { + switch self { + case .history: return "clock" + case .favorites: return "heart.fill" + } + } +} + +struct ClipboardWindow: View { + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var selectedTab: ClipboardTab = .history + @State private var searchText = "" + + var body: some View { + VStack(spacing: 0) { + // Header with tabs and search + ClipboardWindowHeader(selectedTab: $selectedTab, searchText: $searchText) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Content area + ClipboardWindowContent(selectedTab: selectedTab, searchText: searchText) + } + .frame(width: 400, height: 300) + .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10) + } +} + +struct ClipboardWindowHeader: View { + @Binding var selectedTab: ClipboardTab + @Binding var searchText: String + @ObservedObject var clipboardManager = ClipboardManager.shared + + var body: some View { + VStack(spacing: 0) { + // Title and controls + HStack { + Image(systemName: "doc.on.clipboard") + .foregroundColor(.primary) + .font(.system(size: 16, weight: .medium)) + + Text("Clipboard Manager") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + + // Clear button + Button(action: { + if selectedTab == .history { + clipboardManager.clearHistory() + } else { + // Clear favorites + clipboardManager.pinnedItems.removeAll() + clipboardManager.savePinnedItemsToDefaults() + } + }) { + Image(systemName: "trash") + .foregroundColor(.red) + .font(.system(size: 12)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(selectedTab == .history ? clipboardManager.clipboardHistory.isEmpty : clipboardManager.pinnedItems.isEmpty) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + + // 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, 16) + .padding(.bottom, 8) + + // Search bar + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 12)) + + TextField("Search clipboard...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 12)) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + .font(.system(size: 10)) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.1)) + .cornerRadius(6) + .padding(.horizontal, 16) + .padding(.bottom, 8) + } + } +} + +struct ClipboardTabButton: View { + let tab: ClipboardTab + let isSelected: Bool + let action: () -> Void + @ObservedObject var clipboardManager = ClipboardManager.shared + + var itemCount: Int { + switch tab { + case .history: + return clipboardManager.regularHistory.count + case .favorites: + return clipboardManager.pinnedItems.count + } + } + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: tab.icon) + .font(.system(size: 11)) + + Text(tab.rawValue) + .font(.system(size: 11, weight: .medium)) + + if itemCount > 0 { + Text("\(itemCount)") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(isSelected ? .white : .secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + Capsule() + .fill(isSelected ? Color.white.opacity(0.3) : Color.gray.opacity(0.2)) + ) + } + } + .foregroundColor(isSelected ? .white : .secondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isSelected ? Color.blue : Color.clear) + ) + } + .buttonStyle(PlainButtonStyle()) + } +} + +struct ClipboardWindowContent: View { + let selectedTab: ClipboardTab + let searchText: String + @ObservedObject var clipboardManager = ClipboardManager.shared + + var filteredItems: [ClipboardItem] { + let items = selectedTab == .history ? clipboardManager.regularHistory : clipboardManager.pinnedItems + + if searchText.isEmpty { + return items + } else { + return items.filter { item in + item.preview.localizedCaseInsensitiveContains(searchText) || + item.type.displayName.localizedCaseInsensitiveContains(searchText) + } + } + } + + var body: some View { + if filteredItems.isEmpty { + ClipboardEmptyState(tab: selectedTab, hasSearch: !searchText.isEmpty) + } else { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(filteredItems) { item in + ClipboardWindowItemRow(item: item, tab: selectedTab) + } + } + .padding(.vertical, 8) + } + } + } +} + +struct ClipboardEmptyState: View { + let tab: ClipboardTab + let hasSearch: Bool + + var body: some View { + VStack(spacing: 12) { + Image(systemName: hasSearch ? "magnifyingglass" : tab.icon) + .font(.system(size: 32)) + .foregroundColor(.secondary) + + if hasSearch { + Text("No results found") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text("Try adjusting your search terms") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } else { + Text(tab == .history ? "No clipboard history" : "No favorites") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text(tab == .history ? "Copy something to get started" : "Pin items from history to save them here") + .font(.system(size: 12)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 20) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct ClipboardWindowItemRow: View { + let item: ClipboardItem + let tab: ClipboardTab + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var isHovering = false + + var isPinned: Bool { + item.isPinned || clipboardManager.pinnedItems.contains(where: { $0.id == item.id }) + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + // Type icon + Image(systemName: item.type.icon) + .font(.system(size: 14)) + .foregroundColor(.blue) + .frame(width: 20) + + // Content + VStack(alignment: .leading, spacing: 4) { + Text(item.preview) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + Text(item.type.displayName) + .font(.system(size: 10)) + .foregroundColor(.secondary) + + Spacer() + + Text(timeAgoString(from: item.timestamp)) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + + Spacer() + + // Action buttons (shown on hover) + if isHovering { + HStack(spacing: 6) { + // Pin/Unpin button + Button(action: { + clipboardManager.togglePin(for: item) + }) { + Image(systemName: isPinned ? "heart.fill" : "heart") + .font(.system(size: 11)) + .foregroundColor(isPinned ? .red : .gray) + } + .buttonStyle(PlainButtonStyle()) + + // Copy button + Button(action: { + clipboardManager.copyToClipboard(item) + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 11)) + .foregroundColor(.green) + } + .buttonStyle(PlainButtonStyle()) + + // Delete button + Button(action: { + if isPinned { + clipboardManager.unpinItem(item) + } else { + clipboardManager.deleteItem(item) + } + }) { + Image(systemName: "trash") + .font(.system(size: 11)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovering ? Color.gray.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + .onTapGesture { + clipboardManager.copyToClipboard(item) + } + } + + 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" + } + } +} + +#Preview { + ClipboardWindow() + .frame(width: 400, height: 300) + .background(Color.gray.opacity(0.3)) +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 3103c72..dde6ea5 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1589,6 +1589,7 @@ struct ClipboardSettings: View { @Default(.enableClipboardManager) var enableClipboardManager @Default(.clipboardHistorySize) var clipboardHistorySize @Default(.showClipboardIcon) var showClipboardIcon + @Default(.clipboardWindowMode) var clipboardWindowMode var body: some View { Form { @@ -1611,6 +1612,8 @@ struct ClipboardSettings: View { Section { Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon) + Defaults.Toggle("Window Mode", key: .clipboardWindowMode) + HStack { Text("History Size") Spacer() @@ -1631,6 +1634,13 @@ struct ClipboardSettings: View { .foregroundColor(.secondary) } + HStack { + Text("Pinned Items") + Spacer() + Text("\(clipboardManager.pinnedItems.count)") + .foregroundColor(.secondary) + } + HStack { Text("Monitoring Status") Spacer() @@ -1640,7 +1650,7 @@ struct ClipboardSettings: View { } header: { Text("Settings") } footer: { - Text("The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history.") + Text("Window Mode shows clipboard in a moveable window like Raycast with tabs for history and favorites. When disabled, clipboard appears in a popover attached to the Dynamic Island.") } Section { @@ -1649,10 +1659,17 @@ struct ClipboardSettings: View { } .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("This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header.") + Text("Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent.") } if !clipboardManager.clipboardHistory.isEmpty { diff --git a/DynamicIsland/managers/ClipboardManager.swift b/DynamicIsland/managers/ClipboardManager.swift index e4b4e7f..43269c4 100644 --- a/DynamicIsland/managers/ClipboardManager.swift +++ b/DynamicIsland/managers/ClipboardManager.swift @@ -18,6 +18,7 @@ struct ClipboardItem: Identifiable, Codable { let type: ClipboardItemType let timestamp: Date let preview: String + var isPinned: Bool = false // Store different types of data - avoid large binary data in UserDefaults let stringData: String? @@ -90,6 +91,14 @@ struct ClipboardItem: Identifiable, Codable { return try? Data(contentsOf: fileURL) } + // Helper to check if this item has the same content as another + func isSameContent(as other: ClipboardItem) -> Bool { + return stringData == other.stringData && + imageFileName == other.imageFileName && + fileURLs == other.fileURLs && + type == other.type + } + static func generatePreview(stringData: String, type: ClipboardItemType) -> String { switch type { case .text: @@ -149,6 +158,7 @@ class ClipboardManager: ObservableObject { static let shared = ClipboardManager() @Published var clipboardHistory: [ClipboardItem] = [] + @Published var pinnedItems: [ClipboardItem] = [] @Published var isMonitoring: Bool = false private var timer: Timer? @@ -159,6 +169,15 @@ class ClipboardManager: ObservableObject { return Defaults[.clipboardHistorySize] } + // Computed properties for filtered lists + var regularHistory: [ClipboardItem] { + clipboardHistory.filter { !$0.isPinned } + } + + var pinnedHistory: [ClipboardItem] { + pinnedItems + } + // Directory for storing clipboard data files static let clipboardDataDirectory: URL = { let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! @@ -254,6 +273,58 @@ class ClipboardManager: ObservableObject { saveHistoryToDefaults() } + func pinItem(_ item: ClipboardItem) { + // Update the item to be pinned + var pinnedItem = item + pinnedItem.isPinned = true + + // Remove from regular history if it exists there + clipboardHistory.removeAll { $0.id == item.id } + + // Add to pinned items if not already there + if !pinnedItems.contains(where: { $0.id == item.id }) { + pinnedItems.append(pinnedItem) + } + + saveHistoryToDefaults() + savePinnedItemsToDefaults() + } + + func unpinItem(_ item: ClipboardItem) { + // Remove from pinned items + pinnedItems.removeAll { $0.id == item.id } + + // Update the item to be unpinned and add back to regular history + var unpinnedItem = item + unpinnedItem.isPinned = false + + // Add back to regular history at the top + clipboardHistory.insert(unpinnedItem, at: 0) + + // Maintain history size limit + if clipboardHistory.count > maxHistoryItems { + let itemsToDelete = Array(clipboardHistory.dropFirst(maxHistoryItems)) + for oldItem in itemsToDelete { + if let fileName = oldItem.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + clipboardHistory = Array(clipboardHistory.prefix(maxHistoryItems)) + } + + saveHistoryToDefaults() + savePinnedItemsToDefaults() + } + + func togglePin(for item: ClipboardItem) { + if item.isPinned || pinnedItems.contains(where: { $0.id == item.id }) { + unpinItem(item) + } else { + pinItem(item) + } + } + // MARK: - Private Methods private func checkClipboard() { @@ -438,10 +509,21 @@ class ClipboardManager: ObservableObject { } } + func savePinnedItemsToDefaults() { + if let encoded = try? JSONEncoder().encode(pinnedItems) { + UserDefaults.standard.set(encoded, forKey: "ClipboardPinnedItems") + } + } + private func loadHistoryFromDefaults() { if let data = UserDefaults.standard.data(forKey: "ClipboardHistory"), let history = try? JSONDecoder().decode([ClipboardItem].self, from: data) { clipboardHistory = history } + + if let data = UserDefaults.standard.data(forKey: "ClipboardPinnedItems"), + let pinned = try? JSONDecoder().decode([ClipboardItem].self, from: data) { + pinnedItems = pinned + } } } diff --git a/DynamicIsland/managers/ClipboardWindowManager.swift b/DynamicIsland/managers/ClipboardWindowManager.swift new file mode 100644 index 0000000..bb7834a --- /dev/null +++ b/DynamicIsland/managers/ClipboardWindowManager.swift @@ -0,0 +1,104 @@ +// +// ClipboardWindowManager.swift +// DynamicIsland +// +// Created by GitHub Copilot on 12/08/25. +// + +import SwiftUI +import AppKit + +class ClipboardWindowManager: ObservableObject { + static let shared = ClipboardWindowManager() + + private var clipboardWindow: NSWindow? + + private init() {} + + func showClipboardWindow() { + if let existingWindow = clipboardWindow { + existingWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + // Create the window + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + + window.title = "Clipboard Manager" + window.isMovableByWindowBackground = true + window.level = .floating + window.isReleasedWhenClosed = false + window.hasShadow = true + window.titlebarAppearsTransparent = true + window.styleMask.insert(.fullSizeContentView) + + // Set minimum and maximum sizes + window.minSize = NSSize(width: 350, height: 250) + window.maxSize = NSSize(width: 600, height: 500) + + // Center the window + if let screen = NSScreen.main { + let screenFrame = screen.visibleFrame + let windowFrame = window.frame + let x = (screenFrame.width - windowFrame.width) / 2 + screenFrame.minX + let y = (screenFrame.height - windowFrame.height) / 2 + screenFrame.minY + window.setFrameOrigin(NSPoint(x: x, y: y)) + } + + // Set the content view + let contentView = ClipboardWindow() + let hostingView = NSHostingView(rootView: contentView) + window.contentView = hostingView + + // Handle window closing + window.delegate = WindowDelegate { [weak self] in + self?.clipboardWindow = nil + } + + self.clipboardWindow = window + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func hideClipboardWindow() { + clipboardWindow?.close() + } + + func toggleClipboardWindow() { + if let window = clipboardWindow, window.isVisible { + hideClipboardWindow() + } else { + showClipboardWindow() + } + } + + var isWindowVisible: Bool { + return clipboardWindow?.isVisible ?? false + } +} + +private class WindowDelegate: NSObject, NSWindowDelegate { + private let onClose: () -> Void + + init(onClose: @escaping () -> Void) { + self.onClose = onClose + super.init() + } + + func windowWillClose(_ notification: Notification) { + onClose() + } + + func windowShouldClose(_ sender: NSWindow) -> Bool { + // Hide instead of close when user clicks close button + sender.orderOut(nil) + onClose() + return false + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index a97a13b..0f98e94 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -170,6 +170,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 clipboardWindowMode = Key("clipboardWindowMode", default: true) // MARK: Keyboard Shortcuts static let enableShortcuts = Key("enableShortcuts", default: true) From e29745f8f48f1eff324c2f02f767694ba54382c6 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 12 Aug 2025 22:17:23 +0530 Subject: [PATCH 2/3] Dynamic Popup/Dropdown shortcut --- DynamicIsland/DynamicIslandApp.swift | 60 ++++++++++++++++++- DynamicIsland/Localizable.xcstrings | 9 ++- .../Notch/DynamicIslandHeader.swift | 20 ++++++- .../components/Settings/SettingsView.swift | 23 ++++++- .../managers/ClipboardWindowManager.swift | 22 ++++--- DynamicIsland/models/Constants.swift | 24 +++++++- 6 files changed, 139 insertions(+), 19 deletions(-) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 5e09975..b07593f 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -450,8 +450,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { ClipboardManager.shared.startMonitoring() } - // Check if window mode is enabled - if Defaults[.clipboardWindowMode] { + // Determine display mode based on user preference and context + let shouldUseWindow = self.shouldUseWindowMode() + + if shouldUseWindow { // Use window interface ClipboardWindowManager.shared.toggleClipboardWindow() } else { @@ -622,6 +624,60 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSApplication.shared.terminate(nil) } + + // MARK: - Clipboard Display Mode Logic + + private func shouldUseWindowMode() -> Bool { + let displayMode = Defaults[.clipboardDisplayMode] + + switch displayMode { + case .window: + return true + case .popover: + return false + case .auto: + // Auto mode: use window if any app is in fullscreen, otherwise use popover + return isAnyAppInFullscreen() + } + } + + private func isAnyAppInFullscreen() -> Bool { + // Check if any running application is in fullscreen mode + let workspace = NSWorkspace.shared + + // Get all visible applications + let apps = workspace.runningApplications.filter { app in + app.activationPolicy == .regular && !app.isTerminated + } + + // Check each application's windows for fullscreen state + for app in apps { + if app.isActive { + // Check if the frontmost application has fullscreen windows + let windows = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? [[String: Any]] ?? [] + + for window in windows { + if let ownerPID = window[kCGWindowOwnerPID as String] as? Int32, + ownerPID == app.processIdentifier, + let bounds = window[kCGWindowBounds as String] as? [String: Any], + let width = bounds["Width"] as? CGFloat, + let height = bounds["Height"] as? CGFloat { + + // Check if window dimensions match screen dimensions (indicating fullscreen) + for screen in NSScreen.screens { + let screenFrame = screen.frame + if abs(width - screenFrame.width) < 10 && abs(height - screenFrame.height) < 10 { + return true + } + } + } + } + } + } + + return false + } + private func showOnboardingWindow() { if onboardingWindowController == nil { let window = NSWindow( diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 4563a06..cb41d1a 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -136,6 +136,9 @@ }, "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" : { @@ -388,6 +391,9 @@ }, "Disk Write" : { + }, + "Display Mode" : { + }, "Download" : { @@ -1196,9 +1202,6 @@ }, "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : { - }, - "Window Mode shows clipboard in a moveable window like Raycast with tabs for history and favorites. When disabled, clipboard appears in a popover attached to the Dynamic Island." : { - }, "With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : { diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index cbdd9cb..e6c35d8 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -63,7 +63,25 @@ struct DynamicIslandHeader: View { if Defaults[.enableClipboardManager] && Defaults[.showClipboardIcon] { Button(action: { - showClipboardPopover.toggle() + // Use the same smart logic as the keyboard shortcut + let displayMode = Defaults[.clipboardDisplayMode] + let shouldUseWindow: Bool + + switch displayMode { + case .window: + shouldUseWindow = true + case .popover: + shouldUseWindow = false + case .auto: + // For header button clicks, prefer popover since user is already interacting with notch + shouldUseWindow = false + } + + if shouldUseWindow { + ClipboardWindowManager.shared.toggleClipboardWindow() + } else { + showClipboardPopover.toggle() + } }) { Capsule() .fill(.black) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index dde6ea5..a03972b 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1589,7 +1589,7 @@ struct ClipboardSettings: View { @Default(.enableClipboardManager) var enableClipboardManager @Default(.clipboardHistorySize) var clipboardHistorySize @Default(.showClipboardIcon) var showClipboardIcon - @Default(.clipboardWindowMode) var clipboardWindowMode + @Default(.clipboardDisplayMode) var clipboardDisplayMode var body: some View { Form { @@ -1612,7 +1612,24 @@ struct ClipboardSettings: View { Section { Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon) - Defaults.Toggle("Window Mode", key: .clipboardWindowMode) + VStack(alignment: .leading, spacing: 8) { + 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: 140) + } + + Text(clipboardDisplayMode.description) + .font(.caption) + .foregroundColor(.secondary) + .padding(.leading, 0) + } HStack { Text("History Size") @@ -1650,7 +1667,7 @@ struct ClipboardSettings: View { } header: { Text("Settings") } footer: { - Text("Window Mode shows clipboard in a moveable window like Raycast with tabs for history and favorites. When disabled, clipboard appears in a popover attached to the Dynamic Island.") + Text("Auto mode intelligently chooses between popover (desktop) and window (fullscreen apps) for the best experience. You can override this with manual selection.") } Section { diff --git a/DynamicIsland/managers/ClipboardWindowManager.swift b/DynamicIsland/managers/ClipboardWindowManager.swift index bb7834a..a2a0c5c 100644 --- a/DynamicIsland/managers/ClipboardWindowManager.swift +++ b/DynamicIsland/managers/ClipboardWindowManager.swift @@ -17,7 +17,10 @@ class ClipboardWindowManager: ObservableObject { func showClipboardWindow() { if let existingWindow = clipboardWindow { + // Ensure window appears above fullscreen apps + existingWindow.level = .screenSaver existingWindow.makeKeyAndOrderFront(nil) + existingWindow.orderFrontRegardless() // Force window to front even above fullscreen NSApp.activate(ignoringOtherApps: true) return } @@ -32,24 +35,24 @@ class ClipboardWindowManager: ObservableObject { window.title = "Clipboard Manager" window.isMovableByWindowBackground = true - window.level = .floating + window.level = .screenSaver // Use screenSaver level to appear above fullscreen apps window.isReleasedWhenClosed = false window.hasShadow = true window.titlebarAppearsTransparent = true window.styleMask.insert(.fullSizeContentView) + window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] // Allow on all spaces and above fullscreen // Set minimum and maximum sizes window.minSize = NSSize(width: 350, height: 250) window.maxSize = NSSize(width: 600, height: 500) - // Center the window - if let screen = NSScreen.main { - let screenFrame = screen.visibleFrame - let windowFrame = window.frame - let x = (screenFrame.width - windowFrame.width) / 2 + screenFrame.minX - let y = (screenFrame.height - windowFrame.height) / 2 + screenFrame.minY - window.setFrameOrigin(NSPoint(x: x, y: y)) - } + // Center the window on the current screen (important for fullscreen apps) + let currentScreen = NSScreen.main ?? NSScreen.screens.first! + let screenFrame = currentScreen.frame // Use full frame instead of visibleFrame for fullscreen compatibility + let windowFrame = window.frame + let x = (screenFrame.width - windowFrame.width) / 2 + screenFrame.minX + let y = (screenFrame.height - windowFrame.height) / 2 + screenFrame.minY + window.setFrameOrigin(NSPoint(x: x, y: y)) // Set the content view let contentView = ClipboardWindow() @@ -63,6 +66,7 @@ class ClipboardWindowManager: ObservableObject { self.clipboardWindow = window window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() // Force window to front even above fullscreen apps NSApp.activate(ignoringOtherApps: true) } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 0f98e94..7d0e2ae 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -30,6 +30,28 @@ enum CalendarSelectionState: Codable, Defaults.Serializable { case selected(Set) } +enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable { + case auto = "auto" // Automatically choose: popover on desktop, window in fullscreen + case popover = "popover" // Always use popover (attached to notch) + case window = "window" // Always use standalone window + + var displayName: String { + switch self { + case .auto: return "Auto (Smart)" + case .popover: return "Popover" + case .window: return "Window" + } + } + + var description: String { + switch self { + case .auto: return "Automatically chooses the best display mode based on context" + case .popover: return "Always shows clipboard attached to the Dynamic Island" + case .window: return "Always shows clipboard in a standalone moveable window" + } + } +} + enum HideNotchOption: String, Defaults.Serializable { case always case nowPlayingOnly @@ -170,7 +192,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 clipboardWindowMode = Key("clipboardWindowMode", default: true) + static let clipboardDisplayMode = Key("clipboardDisplayMode", default: .auto) // MARK: Keyboard Shortcuts static let enableShortcuts = Key("enableShortcuts", default: true) From e583069870dc10c0b87bbc225b464a21d1b029d6 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Tue, 12 Aug 2025 23:38:10 +0530 Subject: [PATCH 3/3] Clipboard Floating Panel --- DynamicIsland/ContentView.swift | 11 +- DynamicIsland/DynamicIslandApp.swift | 90 +--- DynamicIsland/Localizable.xcstrings | 6 +- .../components/Clipboard/ClipboardPanel.swift | 410 ++++++++++++++++++ .../Clipboard/ClipboardShared.swift | 68 +++ .../Clipboard/ClipboardWindow.swift | 60 --- .../Notch/DynamicIslandHeader.swift | 36 +- .../Notch/DynamicIslandWindow.swift | 4 +- .../components/Settings/SettingsView.swift | 20 - .../managers/ClipboardPanelManager.swift | 55 +++ DynamicIsland/models/Constants.swift | 14 +- .../models/DynamicIslandViewModel.swift | 1 - 12 files changed, 549 insertions(+), 226 deletions(-) create mode 100644 DynamicIsland/components/Clipboard/ClipboardPanel.swift create mode 100644 DynamicIsland/components/Clipboard/ClipboardShared.swift create mode 100644 DynamicIsland/managers/ClipboardPanelManager.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 1291285..d194707 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -124,7 +124,7 @@ struct ContentView: View { } // Only close if mouse leaves and the notch is open and no popovers are active - if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive { + if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive { vm.close() } } @@ -177,13 +177,6 @@ struct ContentView: View { } } } - .onChange(of: vm.isClipboardPopoverActive) { _, newPopoverState in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if !newPopoverState && !isHovering && vm.notchState == .open && !vm.isBatteryPopoverActive { - vm.close() - } - } - } .sensoryFeedback(.alignment, trigger: haptics) .contextMenu { SettingsLink(label: { @@ -524,7 +517,7 @@ struct ContentView: View { } // Close the notch if it's open and no popovers are active - if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive { + if vm.notchState == .open && !vm.isBatteryPopoverActive { vm.close() } } diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index b07593f..42e9ed4 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -450,42 +450,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { ClipboardManager.shared.startMonitoring() } - // Determine display mode based on user preference and context - let shouldUseWindow = self.shouldUseWindowMode() - - if shouldUseWindow { - // Use window interface - ClipboardWindowManager.shared.toggleClipboardWindow() - } else { - // Use traditional popover interface - // Find the appropriate view model based on mouse location - let mouseLocation = NSEvent.mouseLocation - var viewModel = self.vm - - if Defaults[.showOnAllDisplays] { - for screen in NSScreen.screens { - if screen.frame.contains(mouseLocation) { - if let screenViewModel = self.viewModels[screen] { - viewModel = screenViewModel - break - } - } - } - } - - // Open the notch if it's closed - if viewModel.notchState == .closed { - viewModel.open() - - // Wait a bit for the notch to open before toggling clipboard - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - self.coordinator.toggleClipboardPopover() - } - } else { - // If notch is already open, toggle immediately - self.coordinator.toggleClipboardPopover() - } - } + // Always use panel mode + ClipboardPanelManager.shared.toggleClipboardPanel() } if !Defaults[.showOnAllDisplays] { @@ -625,58 +591,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { } - // MARK: - Clipboard Display Mode Logic - - private func shouldUseWindowMode() -> Bool { - let displayMode = Defaults[.clipboardDisplayMode] - - switch displayMode { - case .window: - return true - case .popover: - return false - case .auto: - // Auto mode: use window if any app is in fullscreen, otherwise use popover - return isAnyAppInFullscreen() - } - } - - private func isAnyAppInFullscreen() -> Bool { - // Check if any running application is in fullscreen mode - let workspace = NSWorkspace.shared - - // Get all visible applications - let apps = workspace.runningApplications.filter { app in - app.activationPolicy == .regular && !app.isTerminated - } - - // Check each application's windows for fullscreen state - for app in apps { - if app.isActive { - // Check if the frontmost application has fullscreen windows - let windows = CGWindowListCopyWindowInfo(.optionOnScreenOnly, kCGNullWindowID) as? [[String: Any]] ?? [] - - for window in windows { - if let ownerPID = window[kCGWindowOwnerPID as String] as? Int32, - ownerPID == app.processIdentifier, - let bounds = window[kCGWindowBounds as String] as? [String: Any], - let width = bounds["Width"] as? CGFloat, - let height = bounds["Height"] as? CGFloat { - - // Check if window dimensions match screen dimensions (indicating fullscreen) - for screen in NSScreen.screens { - let screenFrame = screen.frame - if abs(width - screenFrame.width) < 10 && abs(height - screenFrame.height) < 10 { - return true - } - } - } - } - } - } - - return false - } private func showOnboardingWindow() { if onboardingWindowController == nil { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index cb41d1a..7213468 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -391,9 +391,6 @@ }, "Disk Write" : { - }, - "Display Mode" : { - }, "Download" : { @@ -873,6 +870,9 @@ }, "Pin items from history to save them here" : { + }, + "Pin items to add them to favorites" : { + }, "Pinned Items" : { diff --git a/DynamicIsland/components/Clipboard/ClipboardPanel.swift b/DynamicIsland/components/Clipboard/ClipboardPanel.swift new file mode 100644 index 0000000..2576e0a --- /dev/null +++ b/DynamicIsland/components/Clipboard/ClipboardPanel.swift @@ -0,0 +1,410 @@ +// +// ClipboardPanel.swift +// DynamicIsland +// +// Created by GitHub Copilot on 12/08/25. +// + +import AppKit +import SwiftUI + +class ClipboardPanel: NSPanel { + + init() { + super.init( + contentRect: .zero, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: true + ) + + setupWindow() + setupContentView() + } + + // Override to allow the panel to become key window (required for TextField focus) + override var canBecomeKey: Bool { + return true + } + + // Override to allow the panel to become main window (required for text input) + override var canBecomeMain: Bool { + return true + } + + private func setupWindow() { + backgroundColor = .clear + isOpaque = false + hasShadow = true + level = .floating + isMovableByWindowBackground = true // Enable dragging + titlebarAppearsTransparent = true + titleVisibility = .hidden + isFloatingPanel = true // Mark as floating panel for proper behavior + + // Allow dragging from any part of the window + styleMask.insert(.fullSizeContentView) + + collectionBehavior = [ + .canJoinAllSpaces, + .stationary, + .fullScreenAuxiliary // Float above full-screen apps + ] + + // Accept mouse moved events for proper hover behavior + acceptsMouseMovedEvents = true + } + + private func setupContentView() { + let contentView = ClipboardPanelView { + self.close() + } + + let hostingView = NSHostingView(rootView: contentView) + self.contentView = hostingView + + // Set initial size + let preferredSize = CGSize(width: 320, height: 400) + hostingView.setFrameSize(preferredSize) + setContentSize(preferredSize) + } + + func positionNearNotch() { + guard let screen = NSScreen.main else { return } + + let screenFrame = screen.visibleFrame + let panelFrame = frame + + // Position at top center of screen (near where the notch would be) + let xPosition = (screenFrame.width - panelFrame.width) / 2 + screenFrame.minX + let yPosition = screenFrame.maxY - panelFrame.height - 10 // 10px from top + + setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) + } + + func positionNearMouse() { + let mouseLocation = NSEvent.mouseLocation + let panelFrame = frame + + // Position near mouse but ensure it stays on screen + guard let screen = NSScreen.main else { return } + let screenFrame = screen.visibleFrame + + var xPosition = mouseLocation.x - panelFrame.width / 2 + var yPosition = mouseLocation.y - panelFrame.height - 20 + + // Keep within screen bounds + xPosition = max(screenFrame.minX + 10, min(xPosition, screenFrame.maxX - panelFrame.width - 10)) + yPosition = max(screenFrame.minY + 10, min(yPosition, screenFrame.maxY - panelFrame.height - 10)) + + setFrameOrigin(NSPoint(x: xPosition, y: yPosition)) + } +} + +struct ClipboardPanelView: View { + let onClose: () -> Void + @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 + ClipboardPanelHeader( + selectedTab: $selectedTab, + searchText: $searchText, + onClose: onClose + ) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Content + if filteredItems.isEmpty { + ClipboardPanelEmptyState( + hasSearch: !searchText.isEmpty, + isHistoryTab: selectedTab == .history + ) + } else { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(filteredItems) { item in + ClipboardPanelItemRow( + item: item, + isHovered: hoveredItemId == item.id, + isPinned: clipboardManager.pinnedItems.contains(where: { $0.id == item.id }) + ) { hoverId in + hoveredItemId = hoverId + } + } + } + .padding(.vertical, 8) + } + } + } + .frame(width: 320, height: 400) + .background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow)) + .cornerRadius(12) + .shadow(color: .black.opacity(0.4), radius: 20, x: 0, y: 10) + } +} + +struct ClipboardPanelHeader: View { + @Binding var selectedTab: ClipboardTab + @Binding var searchText: String + let onClose: () -> Void + @ObservedObject var clipboardManager = ClipboardManager.shared + @FocusState private var isSearchFieldFocused: Bool + + var body: some View { + VStack(spacing: 8) { + // Title and close button + HStack { + Image(systemName: "doc.on.clipboard") + .foregroundColor(.primary) + .font(.system(size: 16, weight: .medium)) + + Text("Clipboard Manager") + .font(.system(size: 14, 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: 12)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(selectedTab == .history ? clipboardManager.clipboardHistory.isEmpty : clipboardManager.pinnedItems.isEmpty) + + Button(action: onClose) { + Image(systemName: "xmark") + .foregroundColor(.secondary) + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(PlainButtonStyle()) + } + .padding(.horizontal, 16) + .padding(.top, 12) + + // 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, 16) + + // Search bar (always visible) + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + .font(.system(size: 12)) + + TextField("Search clipboard...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + .font(.system(size: 12)) + .focused($isSearchFieldFocused) + + if !searchText.isEmpty { + Button(action: { searchText = "" }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + .font(.system(size: 10)) + } + .buttonStyle(PlainButtonStyle()) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray.opacity(0.1)) + ) + .padding(.horizontal, 16) + } + .padding(.bottom, 8) + } +} + +struct ClipboardPanelEmptyState: View { + let hasSearch: Bool + let isHistoryTab: Bool + + var body: some View { + VStack(spacing: 12) { + Image(systemName: hasSearch ? "magnifyingglass" : (isHistoryTab ? "doc.on.clipboard" : "heart.fill")) + .font(.system(size: 32)) + .foregroundColor(.secondary) + + if hasSearch { + Text("No results found") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text("Try adjusting your search terms") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } else { + Text(isHistoryTab ? "No clipboard history" : "No favorites") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + + Text(isHistoryTab ? "Copy something to get started" : "Pin items to add them to favorites") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct ClipboardPanelItemRow: 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: 12) { + // Type icon + Image(systemName: item.type.icon) + .font(.system(size: 14)) + .foregroundColor(.blue) + .frame(width: 20) + + // Content + VStack(alignment: .leading, spacing: 4) { + Text(item.preview) + .font(.system(size: 12)) + .foregroundColor(.primary) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + Text(item.type.displayName) + .font(.system(size: 10)) + .foregroundColor(.secondary) + + Spacer() + + Text(timeAgoString(from: item.timestamp)) + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + } + + Spacer() + + // Action buttons (shown on hover) + if isHovered { + HStack(spacing: 6) { + // Pin/Unpin button + Button(action: { + if isPinned { + clipboardManager.unpinItem(item) + } else { + clipboardManager.pinItem(item) + } + }) { + Image(systemName: isPinned ? "heart.fill" : "heart") + .font(.system(size: 11)) + .foregroundColor(isPinned ? .red : .gray) + } + .buttonStyle(PlainButtonStyle()) + + // Copy button + Button(action: { + clipboardManager.copyToClipboard(item) + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 11)) + .foregroundColor(.green) + } + .buttonStyle(PlainButtonStyle()) + + // Delete button + Button(action: { + if isPinned { + clipboardManager.unpinItem(item) + } else { + clipboardManager.deleteItem(item) + } + }) { + Image(systemName: "trash") + .font(.system(size: 11)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isHovered ? Color.gray.opacity(0.1) : Color.clear) + ) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + 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 "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" + } + } +} + +#Preview { + ClipboardPanelView { + print("Close panel") + } +} diff --git a/DynamicIsland/components/Clipboard/ClipboardShared.swift b/DynamicIsland/components/Clipboard/ClipboardShared.swift new file mode 100644 index 0000000..33af1b6 --- /dev/null +++ b/DynamicIsland/components/Clipboard/ClipboardShared.swift @@ -0,0 +1,68 @@ +// +// ClipboardShared.swift +// DynamicIsland +// +// Created by GitHub Copilot on 12/08/25. +// + +import SwiftUI + +enum ClipboardTab: String, CaseIterable { + case history = "History" + case favorites = "Favorites" + + var icon: String { + switch self { + case .history: return "clock" + case .favorites: return "heart.fill" + } + } +} + +struct ClipboardTabButton: View { + let tab: ClipboardTab + let isSelected: Bool + let action: () -> Void + @ObservedObject var clipboardManager = ClipboardManager.shared + + var itemCount: Int { + switch tab { + case .history: + return clipboardManager.regularHistory.count + case .favorites: + return clipboardManager.pinnedItems.count + } + } + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: tab.icon) + .font(.system(size: 11)) + + Text(tab.rawValue) + .font(.system(size: 11, weight: .medium)) + + if itemCount > 0 { + Text("\(itemCount)") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(isSelected ? .white : .secondary) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + Capsule() + .fill(isSelected ? Color.white.opacity(0.3) : Color.gray.opacity(0.2)) + ) + } + } + .foregroundColor(isSelected ? .white : .secondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isSelected ? Color.blue : Color.clear) + ) + } + .buttonStyle(PlainButtonStyle()) + } +} diff --git a/DynamicIsland/components/Clipboard/ClipboardWindow.swift b/DynamicIsland/components/Clipboard/ClipboardWindow.swift index b727067..bb8ac72 100644 --- a/DynamicIsland/components/Clipboard/ClipboardWindow.swift +++ b/DynamicIsland/components/Clipboard/ClipboardWindow.swift @@ -8,18 +8,6 @@ import SwiftUI import Defaults -enum ClipboardTab: String, CaseIterable { - case history = "History" - case favorites = "Favorites" - - var icon: String { - switch self { - case .history: return "clock" - case .favorites: return "heart.fill" - } - } -} - struct ClipboardWindow: View { @ObservedObject var clipboardManager = ClipboardManager.shared @State private var selectedTab: ClipboardTab = .history @@ -126,54 +114,6 @@ struct ClipboardWindowHeader: View { } } -struct ClipboardTabButton: View { - let tab: ClipboardTab - let isSelected: Bool - let action: () -> Void - @ObservedObject var clipboardManager = ClipboardManager.shared - - var itemCount: Int { - switch tab { - case .history: - return clipboardManager.regularHistory.count - case .favorites: - return clipboardManager.pinnedItems.count - } - } - - var body: some View { - Button(action: action) { - HStack(spacing: 6) { - Image(systemName: tab.icon) - .font(.system(size: 11)) - - Text(tab.rawValue) - .font(.system(size: 11, weight: .medium)) - - if itemCount > 0 { - Text("\(itemCount)") - .font(.system(size: 9, weight: .bold)) - .foregroundColor(isSelected ? .white : .secondary) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background( - Capsule() - .fill(isSelected ? Color.white.opacity(0.3) : Color.gray.opacity(0.2)) - ) - } - } - .foregroundColor(isSelected ? .white : .secondary) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(isSelected ? Color.blue : Color.clear) - ) - } - .buttonStyle(PlainButtonStyle()) - } -} - struct ClipboardWindowContent: View { let selectedTab: ClipboardTab let searchText: String diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index e6c35d8..0f005d6 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -15,7 +15,6 @@ 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) { @@ -63,25 +62,8 @@ struct DynamicIslandHeader: View { if Defaults[.enableClipboardManager] && Defaults[.showClipboardIcon] { Button(action: { - // Use the same smart logic as the keyboard shortcut - let displayMode = Defaults[.clipboardDisplayMode] - let shouldUseWindow: Bool - - switch displayMode { - case .window: - shouldUseWindow = true - case .popover: - shouldUseWindow = false - case .auto: - // For header button clicks, prefer popover since user is already interacting with notch - shouldUseWindow = false - } - - if shouldUseWindow { - ClipboardWindowManager.shared.toggleClipboardWindow() - } else { - showClipboardPopover.toggle() - } + // Always use panel mode + ClipboardPanelManager.shared.toggleClipboardPanel() }) { Capsule() .fill(.black) @@ -94,18 +76,6 @@ struct DynamicIslandHeader: View { } } .buttonStyle(PlainButtonStyle()) - .popover(isPresented: $showClipboardPopover, arrowEdge: .bottom) { - ClipboardHistoryPopover(isPresented: $showClipboardPopover) - .onAppear { - vm.isClipboardPopoverActive = true - } - .onDisappear { - vm.isClipboardPopoverActive = false - } - } - .onChange(of: showClipboardPopover) { isOpen in - vm.isClipboardPopoverActive = isOpen - } .onAppear { if Defaults[.enableClipboardManager] && !clipboardManager.isMonitoring { clipboardManager.startMonitoring() @@ -155,7 +125,7 @@ struct DynamicIslandHeader: View { .onChange(of: coordinator.shouldToggleClipboardPopover) { _ in // Only toggle if clipboard is enabled if Defaults[.enableClipboardManager] { - showClipboardPopover.toggle() + ClipboardPanelManager.shared.toggleClipboardPanel() } } } diff --git a/DynamicIsland/components/Notch/DynamicIslandWindow.swift b/DynamicIsland/components/Notch/DynamicIslandWindow.swift index df55f02..47e9bc9 100644 --- a/DynamicIsland/components/Notch/DynamicIslandWindow.swift +++ b/DynamicIsland/components/Notch/DynamicIslandWindow.swift @@ -41,10 +41,10 @@ class DynamicIslandWindow: NSPanel { } override var canBecomeKey: Bool { - false + true } override var canBecomeMain: Bool { - false + true } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index a03972b..96a02d3 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1589,7 +1589,6 @@ struct ClipboardSettings: View { @Default(.enableClipboardManager) var enableClipboardManager @Default(.clipboardHistorySize) var clipboardHistorySize @Default(.showClipboardIcon) var showClipboardIcon - @Default(.clipboardDisplayMode) var clipboardDisplayMode var body: some View { Form { @@ -1612,25 +1611,6 @@ struct ClipboardSettings: View { Section { Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon) - VStack(alignment: .leading, spacing: 8) { - 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: 140) - } - - Text(clipboardDisplayMode.description) - .font(.caption) - .foregroundColor(.secondary) - .padding(.leading, 0) - } - HStack { Text("History Size") Spacer() diff --git a/DynamicIsland/managers/ClipboardPanelManager.swift b/DynamicIsland/managers/ClipboardPanelManager.swift new file mode 100644 index 0000000..ec21c5c --- /dev/null +++ b/DynamicIsland/managers/ClipboardPanelManager.swift @@ -0,0 +1,55 @@ +// +// ClipboardPanelManager.swift +// DynamicIsland +// +// Created by GitHub Copilot on 12/08/25. +// + +import AppKit +import SwiftUI + +class ClipboardPanelManager: ObservableObject { + static let shared = ClipboardPanelManager() + + private var clipboardPanel: ClipboardPanel? + + private init() {} + + func showClipboardPanel() { + hideClipboardPanel() // Close any existing panel + + let panel = ClipboardPanel() + panel.positionNearNotch() + + self.clipboardPanel = panel + + // Make the panel key and order front to ensure it can receive focus + panel.makeKeyAndOrderFront(nil) + panel.orderFrontRegardless() + + // Activate the app to ensure proper focus handling + NSApp.activate(ignoringOtherApps: true) + + // Ensure the panel becomes the key window for text input + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + panel.makeKey() + } + } + + func hideClipboardPanel() { + clipboardPanel?.close() + clipboardPanel = nil + } + + func toggleClipboardPanel() { + if let panel = clipboardPanel, panel.isVisible { + hideClipboardPanel() + } else { + showClipboardPanel() + } + } + + var isPanelVisible: Bool { + return clipboardPanel?.isVisible ?? false + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 7d0e2ae..ee8d6b4 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -31,23 +31,17 @@ enum CalendarSelectionState: Codable, Defaults.Serializable { } enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable { - case auto = "auto" // Automatically choose: popover on desktop, window in fullscreen - case popover = "popover" // Always use popover (attached to notch) - case window = "window" // Always use standalone window + case popover = "popover" // Always use floating panel var displayName: String { switch self { - case .auto: return "Auto (Smart)" - case .popover: return "Popover" - case .window: return "Window" + case .popover: return "Panel" } } var description: String { switch self { - case .auto: return "Automatically chooses the best display mode based on context" - case .popover: return "Always shows clipboard attached to the Dynamic Island" - case .window: return "Always shows clipboard in a standalone moveable window" + case .popover: return "Shows clipboard in a floating panel near the notch" } } } @@ -192,7 +186,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: .auto) + static let clipboardDisplayMode = Key("clipboardDisplayMode", default: .popover) // MARK: Keyboard Shortcuts static let enableShortcuts = Key("enableShortcuts", default: true) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index 54c64f4..e29d114 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -29,7 +29,6 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var hideOnClosed: Bool = true @Published var isHoveringCalendar: Bool = false @Published var isBatteryPopoverActive: Bool = false - @Published var isClipboardPopoverActive: Bool = false let webcamManager = WebcamManager.shared @Published var isCameraExpanded: Bool = false