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 613c2cd..42e9ed4 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -445,38 +445,13 @@ 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() } + + // Always use panel mode + ClipboardPanelManager.shared.toggleClipboardPanel() } if !Defaults[.showOnAllDisplays] { @@ -615,6 +590,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSApplication.shared.terminate(nil) } + + private func showOnboardingWindow() { if onboardingWindowController == nil { let window = NSWindow( diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 8ec7b0f..7213468 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" : { @@ -271,9 +274,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 +792,15 @@ }, "No extension installed" : { + }, + "No favorites" : { + }, "No Graphs Enabled" : { + }, + "No results found" : { + }, "Non-notch display height" : { @@ -852,6 +867,15 @@ } } } + }, + "Pin items from history to save them here" : { + + }, + "Pin items to add them to favorites" : { + + }, + "Pinned Items" : { + }, "Plugged In" : { @@ -920,6 +944,9 @@ }, "Running" : { + }, + "Search clipboard..." : { + }, "seconds" : { "extractionState" : "stale", @@ -1083,15 +1110,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 +1143,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" : { 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 new file mode 100644 index 0000000..bb8ac72 --- /dev/null +++ b/DynamicIsland/components/Clipboard/ClipboardWindow.swift @@ -0,0 +1,303 @@ +// +// ClipboardWindow.swift +// DynamicIsland +// +// Created by GitHub Copilot on 12/08/25. +// + +import SwiftUI +import Defaults + +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 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/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index cbdd9cb..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,7 +62,8 @@ struct DynamicIslandHeader: View { if Defaults[.enableClipboardManager] && Defaults[.showClipboardIcon] { Button(action: { - showClipboardPopover.toggle() + // Always use panel mode + ClipboardPanelManager.shared.toggleClipboardPanel() }) { Capsule() .fill(.black) @@ -76,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() @@ -137,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 3103c72..96a02d3 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1631,6 +1631,13 @@ struct ClipboardSettings: View { .foregroundColor(.secondary) } + HStack { + Text("Pinned Items") + Spacer() + Text("\(clipboardManager.pinnedItems.count)") + .foregroundColor(.secondary) + } + HStack { Text("Monitoring Status") Spacer() @@ -1640,7 +1647,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("Auto mode intelligently chooses between popover (desktop) and window (fullscreen apps) for the best experience. You can override this with manual selection.") } Section { @@ -1649,10 +1656,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/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/managers/ClipboardWindowManager.swift b/DynamicIsland/managers/ClipboardWindowManager.swift new file mode 100644 index 0000000..a2a0c5c --- /dev/null +++ b/DynamicIsland/managers/ClipboardWindowManager.swift @@ -0,0 +1,108 @@ +// +// 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 { + // 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 + } + + // 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 = .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 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() + 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) + window.orderFrontRegardless() // Force window to front even above fullscreen apps + 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..ee8d6b4 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -30,6 +30,22 @@ enum CalendarSelectionState: Codable, Defaults.Serializable { case selected(Set) } +enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable { + case popover = "popover" // Always use floating panel + + var displayName: String { + switch self { + case .popover: return "Panel" + } + } + + var description: String { + switch self { + case .popover: return "Shows clipboard in a floating panel near the notch" + } + } +} + enum HideNotchOption: String, Defaults.Serializable { case always case nowPlayingOnly @@ -170,6 +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: .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