From 7396f0242ac354a8c796dec85caa77ca4723e320 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 17 Jul 2025 19:33:21 +0530 Subject: [PATCH 1/4] Update Names --- DynamicIsland/components/Notch/NotchStatsView.swift | 2 +- DynamicIsland/components/Settings/SettingsView.swift | 2 +- DynamicIsland/components/Tabs/TabSelectionView.swift | 2 +- DynamicIsland/enums/generic.swift | 2 +- DynamicIsland/managers/StatsManager.swift | 2 +- DynamicIsland/models/Constants.swift | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/DynamicIsland/components/Notch/NotchStatsView.swift b/DynamicIsland/components/Notch/NotchStatsView.swift index 64f3e79..69eea7f 100644 --- a/DynamicIsland/components/Notch/NotchStatsView.swift +++ b/DynamicIsland/components/Notch/NotchStatsView.swift @@ -3,7 +3,7 @@ // DynamicIsland // // Stats tab view for system performance monitoring -// +// Created by Hariharan Mudaliar import SwiftUI import Defaults diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 61e9f49..7a2a79e 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -3,7 +3,7 @@ // DynamicIsland // // Created by Richard Kunkli on 07/08/2024. -// +// Modified by Hariharan Mudaliar import AVFoundation import Defaults diff --git a/DynamicIsland/components/Tabs/TabSelectionView.swift b/DynamicIsland/components/Tabs/TabSelectionView.swift index 8336f5d..f35dd7b 100644 --- a/DynamicIsland/components/Tabs/TabSelectionView.swift +++ b/DynamicIsland/components/Tabs/TabSelectionView.swift @@ -3,7 +3,7 @@ // DynamicIsland // // Created by Hugo Persson on 2024-08-25. -// +// Modified by Hariharan Mudaliar import SwiftUI diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index 3f082d2..c6c4f32 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -3,7 +3,7 @@ // DynamicIsland // // Created by Harsh Vardhan Goswami on 04/08/24. -// +// Modified by Hariharan Mudaliar import Foundation import Defaults diff --git a/DynamicIsland/managers/StatsManager.swift b/DynamicIsland/managers/StatsManager.swift index 31cb82f..329142f 100644 --- a/DynamicIsland/managers/StatsManager.swift +++ b/DynamicIsland/managers/StatsManager.swift @@ -3,7 +3,7 @@ // DynamicIsland // // System performance monitoring for the Dynamic Island Stats feature -// +// Created by Hariharan Mudaliar import Foundation import Combine diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 83c20a2..56f7a18 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -3,7 +3,7 @@ // DynamicIsland // // Created by Richard Kunkli on 2024. 10. 17.. -// +// Modified by Hariharan Mudaliar import SwiftUI import Defaults From 4890e6fc28f6d420ed3d0b70640492cf65c60b96 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 17 Jul 2025 20:21:49 +0530 Subject: [PATCH 2/4] Working Text Clipboard --- DynamicIsland/ContentView.swift | 15 +- DynamicIsland/Localizable.xcstrings | 45 ++++ .../Notch/ClipboardHistoryPopover.swift | 190 +++++++++++++++ .../Notch/DynamicIslandHeader.swift | 38 +++ .../components/Settings/SettingsView.swift | 125 ++++++++++ DynamicIsland/managers/ClipboardManager.swift | 217 ++++++++++++++++++ DynamicIsland/models/Constants.swift | 4 + .../models/DynamicIslandViewModel.swift | 1 + 8 files changed, 631 insertions(+), 4 deletions(-) create mode 100644 DynamicIsland/components/Notch/ClipboardHistoryPopover.swift create mode 100644 DynamicIsland/managers/ClipboardManager.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index bafa0a3..f93f7f4 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -94,8 +94,8 @@ struct ContentView: View { isHovering = hovering } - // Only close if mouse leaves and the notch is open - if !hovering && vm.notchState == .open { + // Only close if mouse leaves and the notch is open and no popovers are active + if !hovering && vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive { vm.close() } } @@ -148,6 +148,13 @@ 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: { @@ -477,8 +484,8 @@ struct ContentView: View { isHovering = false } - // Close the notch if it's open and battery popover is not active - if vm.notchState == .open && !vm.isBatteryPopoverActive { + // Close the notch if it's open and no popovers are active + if vm.notchState == .open && !vm.isBatteryPopoverActive && !vm.isClipboardPopoverActive { vm.close() } } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 4093990..e4984b9 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -46,6 +46,15 @@ }, "• New feature 1" : { + }, + "3 items" : { + + }, + "5 items" : { + + }, + "10 items" : { + }, "About" : { "localizations" : { @@ -70,6 +79,9 @@ }, "Access Denied" : { + }, + "Actions" : { + }, "Activate" : { @@ -244,9 +256,21 @@ }, "Clear" : { + }, + "Clear Clipboard History" : { + }, "Clear Data" : { + }, + "Clipboard" : { + + }, + "Clipboard History" : { + + }, + "Clipboard Manager" : { + }, "Close" : { @@ -266,9 +290,18 @@ }, "Controls" : { + }, + "Copy something to get started" : { + }, "CPU Usage" : { + }, + "Current History" : { + + }, + "Current Items" : { + }, "Custom color" : { "extractionState" : "stale", @@ -479,6 +512,9 @@ }, "High" : { + }, + "History Size" : { + }, "https://github.com/th-ch/youtube-music" : { @@ -616,6 +652,9 @@ }, "Mirror shape" : { + }, + "Monitor clipboard changes and keep a history of recent copies. The clipboard button will appear in the header next to the settings gear." : { + }, "Monitoring Status" : { @@ -637,6 +676,9 @@ }, "Never hide" : { + }, + "No clipboard history" : { + }, "No custom animation available" : { @@ -960,6 +1002,9 @@ }, "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" : { diff --git a/DynamicIsland/components/Notch/ClipboardHistoryPopover.swift b/DynamicIsland/components/Notch/ClipboardHistoryPopover.swift new file mode 100644 index 0000000..73dc37e --- /dev/null +++ b/DynamicIsland/components/Notch/ClipboardHistoryPopover.swift @@ -0,0 +1,190 @@ +// +// ClipboardHistoryPopover.swift +// DynamicIsland +// +// Created by GitHub Copilot on 17/07/25. +// + +import SwiftUI + +struct ClipboardHistoryPopover: View { + @ObservedObject var clipboardManager = ClipboardManager.shared + @Binding var isPresented: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Header + HStack { + Image(systemName: "doc.on.clipboard") + .foregroundColor(.white) + .font(.system(size: 16, weight: .medium)) + Text("Clipboard History") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + + Spacer() + + Button(action: { + clipboardManager.clearHistory() + }) { + Image(systemName: "trash") + .foregroundColor(.red) + .font(.system(size: 12)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(clipboardManager.clipboardHistory.isEmpty) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + .background(Color.gray.opacity(0.3)) + + // Content + if clipboardManager.clipboardHistory.isEmpty { + EmptyClipboardView() + } else { + ClipboardItemsList() + } + } + .frame(width: 280) + .frame(maxHeight: 200) + .background(Color.black.opacity(0.95)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.gray.opacity(0.2), lineWidth: 1) + ) + .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) + } + + @ViewBuilder + private func EmptyClipboardView() -> some View { + VStack(spacing: 8) { + Image(systemName: "clipboard") + .font(.system(size: 24)) + .foregroundColor(.gray) + Text("No clipboard history") + .font(.system(size: 12)) + .foregroundColor(.gray) + Text("Copy something to get started") + .font(.system(size: 10)) + .foregroundColor(.gray.opacity(0.7)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 20) + } + + @ViewBuilder + private func ClipboardItemsList() -> some View { + ScrollView { + LazyVStack(spacing: 1) { + ForEach(clipboardManager.clipboardHistory) { item in + ClipboardItemRow(item: item) { + isPresented = false + } + } + } + } + .frame(maxHeight: 150) + } +} + +struct ClipboardItemRow: View { + let item: ClipboardItem + let onCopy: () -> Void + @ObservedObject var clipboardManager = ClipboardManager.shared + @State private var isHovering = false + + var body: some View { + HStack(alignment: .top, spacing: 8) { + // Type icon + Image(systemName: item.type.icon) + .font(.system(size: 12)) + .foregroundColor(.blue) + .frame(width: 16) + + // Content + VStack(alignment: .leading, spacing: 2) { + Text(item.preview) + .font(.system(size: 11)) + .foregroundColor(.white) + .lineLimit(2) + .multilineTextAlignment(.leading) + + HStack { + Text(item.type.displayName) + .font(.system(size: 9)) + .foregroundColor(.gray) + + Spacer() + + Text(timeAgoString(from: item.timestamp)) + .font(.system(size: 9)) + .foregroundColor(.gray) + } + } + + Spacer() + + // Action buttons (shown on hover) + if isHovering { + HStack(spacing: 4) { + Button(action: { + clipboardManager.copyToClipboard(item) + onCopy() + }) { + Image(systemName: "doc.on.doc") + .font(.system(size: 10)) + .foregroundColor(.green) + } + .buttonStyle(PlainButtonStyle()) + + Button(action: { + clipboardManager.deleteItem(item) + }) { + Image(systemName: "trash") + .font(.system(size: 10)) + .foregroundColor(.red) + } + .buttonStyle(PlainButtonStyle()) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isHovering ? Color.white.opacity(0.1) : Color.clear) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.2)) { + isHovering = hovering + } + } + .onTapGesture { + clipboardManager.copyToClipboard(item) + onCopy() + } + } + + 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 { + ClipboardHistoryPopover(isPresented: .constant(true)) + .padding() + .background(Color.gray) +} diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index ed6fd55..13115dd 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -13,7 +13,10 @@ struct DynamicIslandHeader: View { @EnvironmentObject var webcamManager: WebcamManager @ObservedObject var batteryModel = BatteryStatusViewModel.shared @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) { HStack { @@ -61,6 +64,41 @@ struct DynamicIslandHeader: View { } .buttonStyle(PlainButtonStyle()) } + + if Defaults[.enableClipboardManager] { + Button(action: { + showClipboardPopover.toggle() + }) { + Capsule() + .fill(.black) + .frame(width: 30, height: 30) + .overlay { + Image(systemName: "doc.on.clipboard") + .foregroundColor(.white) + .padding() + .imageScale(.medium) + } + } + .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() + } + } + } + if Defaults[.settingsIconInNotch] { Button(action: { SettingsWindowController.shared.showWindow() diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 7a2a79e..f261ec0 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -63,6 +63,9 @@ struct SettingsView: View { NavigationLink(value: "Stats") { Label("Stats", systemImage: "chart.line.uptrend.xyaxis") } + NavigationLink(value: "Clipboard") { + Label("Clipboard", systemImage: "doc.on.clipboard") + } NavigationLink(value: "Shortcuts") { Label("Shortcuts", systemImage: "keyboard") } @@ -97,6 +100,8 @@ struct SettingsView: View { Shelf() case "Stats": Stats() + case "Clipboard": + ClipboardSettings() case "Shortcuts": Shortcuts() case "Extensions": @@ -1300,6 +1305,126 @@ func warningBadge(_ text: String, _ description: String) -> some View { } } +struct ClipboardSettings: View { + @ObservedObject var clipboardManager = ClipboardManager.shared + @Default(.enableClipboardManager) var enableClipboardManager + @Default(.clipboardHistorySize) var clipboardHistorySize + + var body: some View { + Form { + Section { + Defaults.Toggle("Enable Clipboard Manager", key: .enableClipboardManager) + .onChange(of: enableClipboardManager) { enabled in + if enabled { + clipboardManager.startMonitoring() + } else { + clipboardManager.stopMonitoring() + } + } + } header: { + Text("Clipboard Manager") + } footer: { + Text("Monitor clipboard changes and keep a history of recent copies. The clipboard button will appear in the header next to the settings gear.") + } + + if enableClipboardManager { + Section { + HStack { + Text("History Size") + Spacer() + Picker("History Size", selection: $clipboardHistorySize) { + Text("3 items").tag(3) + Text("5 items").tag(5) + Text("10 items").tag(10) + } + .pickerStyle(.menu) + .frame(width: 100) + } + + HStack { + Text("Current Items") + Spacer() + Text("\(clipboardManager.clipboardHistory.count)") + .foregroundColor(.secondary) + } + + HStack { + Text("Monitoring Status") + Spacer() + Text(clipboardManager.isMonitoring ? "Active" : "Stopped") + .foregroundColor(clipboardManager.isMonitoring ? .green : .secondary) + } + } header: { + Text("Settings") + } + + Section { + Button("Clear Clipboard History") { + clipboardManager.clearHistory() + } + .foregroundColor(.red) + .disabled(clipboardManager.clipboardHistory.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.") + } + + if !clipboardManager.clipboardHistory.isEmpty { + Section { + ForEach(clipboardManager.clipboardHistory) { item in + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: item.type.icon) + .foregroundColor(.blue) + .frame(width: 16) + Text(item.type.displayName) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Text(timeAgoString(from: item.timestamp)) + .font(.caption2) + .foregroundColor(.secondary) + } + Text(item.preview) + .font(.system(.body, design: .monospaced)) + .lineLimit(2) + } + .padding(.vertical, 2) + } + } header: { + Text("Current History") + } + } + } + } + .formStyle(.grouped) + .navigationTitle("Clipboard") + .onAppear { + if enableClipboardManager && !clipboardManager.isMonitoring { + clipboardManager.startMonitoring() + } + } + } + + private func timeAgoString(from date: Date) -> String { + let interval = Date().timeIntervalSince(date) + + if interval < 60 { + return "Just now" + } else if interval < 3600 { + let minutes = Int(interval / 60) + return "\(minutes)m ago" + } else if interval < 86400 { + let hours = Int(interval / 3600) + return "\(hours)h ago" + } else { + let days = Int(interval / 86400) + return "\(days)d ago" + } + } +} + #Preview { HUD() } diff --git a/DynamicIsland/managers/ClipboardManager.swift b/DynamicIsland/managers/ClipboardManager.swift new file mode 100644 index 0000000..7ab1afe --- /dev/null +++ b/DynamicIsland/managers/ClipboardManager.swift @@ -0,0 +1,217 @@ +// +// ClipboardManager.swift +// DynamicIsland +// +// Created by GitHub Copilot on 17/07/25. +// + +import AppKit +import SwiftUI +import Combine +import Foundation + +// Clipboard item data structure +struct ClipboardItem: Identifiable, Codable { + let id = UUID() + let content: String + let type: ClipboardItemType + let timestamp: Date + let preview: String + + init(content: String, type: ClipboardItemType) { + self.content = content + self.type = type + self.timestamp = Date() + self.preview = ClipboardItem.generatePreview(content: content, type: type) + } + + static func generatePreview(content: String, type: ClipboardItemType) -> String { + switch type { + case .text: + return String(content.prefix(50)) + case .url: + if let url = URL(string: content) { + return url.lastPathComponent.isEmpty ? url.host ?? content : url.lastPathComponent + } + return String(content.prefix(50)) + case .file: + if let url = URL(string: content) { + return url.lastPathComponent + } + return "File" + case .image: + return "Image" + case .rtf: + // Remove RTF formatting for preview + let stripped = content.replacingOccurrences(of: "\\[^\\s]*\\s?", with: "", options: .regularExpression) + return String(stripped.prefix(50)) + case .unknown: + return String(content.prefix(50)) + } + } +} + +enum ClipboardItemType: String, CaseIterable, Codable { + case text = "text" + case url = "url" + case file = "file" + case image = "image" + case rtf = "rtf" + case unknown = "unknown" + + var icon: String { + switch self { + case .text: return "doc.text" + case .url: return "link" + case .file: return "doc" + case .image: return "photo" + case .rtf: return "doc.richtext" + case .unknown: return "questionmark.circle" + } + } + + var displayName: String { + switch self { + case .text: return "Text" + case .url: return "URL" + case .file: return "File" + case .image: return "Image" + case .rtf: return "Rich Text" + case .unknown: return "Unknown" + } + } +} + +class ClipboardManager: ObservableObject { + static let shared = ClipboardManager() + + @Published var clipboardHistory: [ClipboardItem] = [] + @Published var isMonitoring: Bool = false + + private var timer: Timer? + private var lastChangeCount: Int = 0 + private let maxHistoryItems = 3 + + private init() { + lastChangeCount = NSPasteboard.general.changeCount + loadHistoryFromDefaults() + } + + // MARK: - Public Methods + + func startMonitoring() { + guard !isMonitoring else { return } + + isMonitoring = true + timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.checkClipboard() + } + } + + func stopMonitoring() { + isMonitoring = false + timer?.invalidate() + timer = nil + } + + func copyToClipboard(_ item: ClipboardItem) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(item.content, forType: .string) + } + + func deleteItem(_ item: ClipboardItem) { + clipboardHistory.removeAll { $0.id == item.id } + saveHistoryToDefaults() + } + + func clearHistory() { + clipboardHistory.removeAll() + saveHistoryToDefaults() + } + + // MARK: - Private Methods + + private func checkClipboard() { + let currentChangeCount = NSPasteboard.general.changeCount + + guard currentChangeCount != lastChangeCount else { return } + lastChangeCount = currentChangeCount + + guard let clipboardItem = getCurrentClipboardItem() else { return } + + // Don't add duplicate items + if !clipboardHistory.contains(where: { $0.content == clipboardItem.content && $0.type == clipboardItem.type }) { + addToHistory(clipboardItem) + } + } + + private func getCurrentClipboardItem() -> ClipboardItem? { + let pasteboard = NSPasteboard.general + + // Check for URL first + if let url = pasteboard.string(forType: .URL) ?? pasteboard.string(forType: .fileURL) { + return ClipboardItem(content: url, type: .url) + } + + // Check for file URLs + if let fileURLs = pasteboard.propertyList(forType: .fileURL) as? [String], !fileURLs.isEmpty { + return ClipboardItem(content: fileURLs.first!, type: .file) + } + + // Check for images + if pasteboard.canReadItem(withDataConformingToTypes: [NSPasteboard.PasteboardType.tiff.rawValue, NSPasteboard.PasteboardType.png.rawValue]) { + return ClipboardItem(content: "Image data", type: .image) + } + + // Check for RTF + if let rtfData = pasteboard.data(forType: .rtf), + let rtfString = NSAttributedString(rtf: rtfData, documentAttributes: nil)?.string { + return ClipboardItem(content: rtfString, type: .rtf) + } + + // Check for plain text + if let string = pasteboard.string(forType: .string) { + // Determine if it's a URL + if string.hasPrefix("http://") || string.hasPrefix("https://") || string.hasPrefix("file://") { + return ClipboardItem(content: string, type: .url) + } + return ClipboardItem(content: string, type: .text) + } + + return nil + } + + private func addToHistory(_ item: ClipboardItem) { + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Remove any existing items with the same content + self.clipboardHistory.removeAll { $0.content == item.content } + + // Add to beginning of array + self.clipboardHistory.insert(item, at: 0) + + // Keep only the most recent items + if self.clipboardHistory.count > self.maxHistoryItems { + self.clipboardHistory = Array(self.clipboardHistory.prefix(self.maxHistoryItems)) + } + + self.saveHistoryToDefaults() + } + } + + // MARK: - Persistence + + private func saveHistoryToDefaults() { + if let encoded = try? JSONEncoder().encode(clipboardHistory) { + UserDefaults.standard.set(encoded, forKey: "ClipboardHistory") + } + } + + private func loadHistoryFromDefaults() { + if let data = UserDefaults.standard.data(forKey: "ClipboardHistory"), + let history = try? JSONDecoder().decode([ClipboardItem].self, from: data) { + clipboardHistory = history + } + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 56f7a18..120e444 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -157,6 +157,10 @@ extension Defaults.Keys { // MARK: Stats Feature static let enableStatsFeature = Key("enableStatsFeature", default: false) + // MARK: Clipboard Feature + static let enableClipboardManager = Key("enableClipboardManager", default: true) + static let clipboardHistorySize = Key("clipboardHistorySize", default: 3) + // Helper to determine the default media controller based on macOS version static var defaultMediaController: MediaControllerType { if #available(macOS 15.4, *) { diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index a7efe00..d73371b 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -29,6 +29,7 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var hideOnClosed: Bool = true @Published var isHoveringCalendar: Bool = false @Published var isBatteryPopoverActive: Bool = false + @Published var isClipboardPopoverActive: Bool = false @Published var screen: String? From bb0b66a50e9b16375ceadc628a51baa34edc6f9b Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 17 Jul 2025 21:51:08 +0530 Subject: [PATCH 3/4] Fully Working Clipboard --- DynamicIsland/managers/ClipboardManager.swift | 315 +++++++++++++++--- 1 file changed, 270 insertions(+), 45 deletions(-) diff --git a/DynamicIsland/managers/ClipboardManager.swift b/DynamicIsland/managers/ClipboardManager.swift index 7ab1afe..b6ede38 100644 --- a/DynamicIsland/managers/ClipboardManager.swift +++ b/DynamicIsland/managers/ClipboardManager.swift @@ -9,44 +9,106 @@ import AppKit import SwiftUI import Combine import Foundation +import UniformTypeIdentifiers // Clipboard item data structure struct ClipboardItem: Identifiable, Codable { let id = UUID() - let content: String let type: ClipboardItemType let timestamp: Date let preview: String - init(content: String, type: ClipboardItemType) { - self.content = content + // Store different types of data - avoid large binary data in UserDefaults + let stringData: String? + let imageFileName: String? // Store filename instead of data + let fileURLs: [String]? + let rtfData: Data? // RTF is typically small, so we can keep this + + init(stringData: String, type: ClipboardItemType) { + self.stringData = stringData + self.imageFileName = nil + self.fileURLs = nil + self.rtfData = nil self.type = type self.timestamp = Date() - self.preview = ClipboardItem.generatePreview(content: content, type: type) + self.preview = ClipboardItem.generatePreview(stringData: stringData, type: type) } - static func generatePreview(content: String, type: ClipboardItemType) -> String { + init(imageData: Data) { + self.stringData = nil + self.fileURLs = nil + self.rtfData = nil + self.type = .image + self.timestamp = Date() + + // Save image data to temporary file instead of storing in UserDefaults + let fileName = "clipboard_image_\(UUID().uuidString).png" + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + + do { + try imageData.write(to: fileURL) + self.imageFileName = fileName + self.preview = "Image (\(ByteCountFormatter.string(fromByteCount: Int64(imageData.count), countStyle: .file)))" + } catch { + print("Failed to save image data: \(error)") + self.imageFileName = nil + self.preview = "Image (failed to save)" + } + } + + init(fileURLs: [String]) { + self.stringData = nil + self.imageFileName = nil + self.fileURLs = fileURLs + self.rtfData = nil + self.type = .file + self.timestamp = Date() + + if fileURLs.count == 1, let url = URL(string: fileURLs.first!) { + self.preview = url.lastPathComponent + } else { + self.preview = "\(fileURLs.count) files" + } + } + + init(rtfData: Data, plainText: String) { + // RTF data is typically small, so we can keep it in UserDefaults + self.stringData = plainText + self.imageFileName = nil + self.fileURLs = nil + self.rtfData = rtfData.count > 100000 ? nil : rtfData // Skip very large RTF files + self.type = .rtf + self.timestamp = Date() + self.preview = String(plainText.prefix(50)) + } + + // Helper to get image data from file + func getImageData() -> Data? { + guard let fileName = imageFileName else { return nil } + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + return try? Data(contentsOf: fileURL) + } + + static func generatePreview(stringData: String, type: ClipboardItemType) -> String { switch type { case .text: - return String(content.prefix(50)) + return String(stringData.prefix(50)) case .url: - if let url = URL(string: content) { - return url.lastPathComponent.isEmpty ? url.host ?? content : url.lastPathComponent + if let url = URL(string: stringData) { + return url.lastPathComponent.isEmpty ? url.host ?? stringData : url.lastPathComponent } - return String(content.prefix(50)) + return String(stringData.prefix(50)) case .file: - if let url = URL(string: content) { + if let url = URL(string: stringData) { return url.lastPathComponent } return "File" case .image: return "Image" case .rtf: - // Remove RTF formatting for preview - let stripped = content.replacingOccurrences(of: "\\[^\\s]*\\s?", with: "", options: .regularExpression) - return String(stripped.prefix(50)) + return String(stringData.prefix(50)) case .unknown: - return String(content.prefix(50)) + return String(stringData.prefix(50)) } } } @@ -92,9 +154,25 @@ class ClipboardManager: ObservableObject { private var lastChangeCount: Int = 0 private let maxHistoryItems = 3 + // Directory for storing clipboard data files + static let clipboardDataDirectory: URL = { + let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + let clipboardDir = documentsPath.appendingPathComponent("ClipboardData") + + // Create directory if it doesn't exist + try? FileManager.default.createDirectory(at: clipboardDir, withIntermediateDirectories: true) + + return clipboardDir + }() + private init() { lastChangeCount = NSPasteboard.general.changeCount loadHistoryFromDefaults() + cleanupOldFiles() + } + + deinit { + stopMonitoring() } // MARK: - Public Methods @@ -115,16 +193,58 @@ class ClipboardManager: ObservableObject { } func copyToClipboard(_ item: ClipboardItem) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(item.content, forType: .string) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + + switch item.type { + case .text, .url: + if let stringData = item.stringData { + pasteboard.setString(stringData, forType: .string) + } + case .image: + if let imageData = item.getImageData() { + pasteboard.setData(imageData, forType: .png) + } + case .file: + if let fileURLs = item.fileURLs { + let urls = fileURLs.compactMap { URL(string: $0) } + pasteboard.writeObjects(urls as [NSPasteboardWriting]) + } + case .rtf: + if let rtfData = item.rtfData { + pasteboard.setData(rtfData, forType: .rtf) + } + // Also set plain text as fallback + if let stringData = item.stringData { + pasteboard.setString(stringData, forType: .string) + } + case .unknown: + if let stringData = item.stringData { + pasteboard.setString(stringData, forType: .string) + } + } } func deleteItem(_ item: ClipboardItem) { + // Clean up associated files + if let fileName = item.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + clipboardHistory.removeAll { $0.id == item.id } saveHistoryToDefaults() } func clearHistory() { + // Clean up all associated files + for item in clipboardHistory { + if let fileName = item.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + clipboardHistory.removeAll() saveHistoryToDefaults() } @@ -140,7 +260,7 @@ class ClipboardManager: ObservableObject { guard let clipboardItem = getCurrentClipboardItem() else { return } // Don't add duplicate items - if !clipboardHistory.contains(where: { $0.content == clipboardItem.content && $0.type == clipboardItem.type }) { + if !clipboardHistory.contains(where: { isSameContent($0, clipboardItem) }) { addToHistory(clipboardItem) } } @@ -148,34 +268,84 @@ class ClipboardManager: ObservableObject { private func getCurrentClipboardItem() -> ClipboardItem? { let pasteboard = NSPasteboard.general - // Check for URL first - if let url = pasteboard.string(forType: .URL) ?? pasteboard.string(forType: .fileURL) { - return ClipboardItem(content: url, type: .url) - } + // Step 1: Check what types are available + let hasFileURLs = pasteboard.canReadObject(forClasses: [NSURL.self], options: nil) + let hasImageData = pasteboard.data(forType: .png) != nil || + pasteboard.data(forType: .tiff) != nil || + pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) != nil + let hasString = pasteboard.string(forType: .string) != nil - // Check for file URLs - if let fileURLs = pasteboard.propertyList(forType: .fileURL) as? [String], !fileURLs.isEmpty { - return ClipboardItem(content: fileURLs.first!, type: .file) - } + // Step 2: Smart detection based on context - // Check for images - if pasteboard.canReadItem(withDataConformingToTypes: [NSPasteboard.PasteboardType.tiff.rawValue, NSPasteboard.PasteboardType.png.rawValue]) { - return ClipboardItem(content: "Image data", type: .image) - } - - // Check for RTF - if let rtfData = pasteboard.data(forType: .rtf), - let rtfString = NSAttributedString(rtf: rtfData, documentAttributes: nil)?.string { - return ClipboardItem(content: rtfString, type: .rtf) - } - - // Check for plain text - if let string = pasteboard.string(forType: .string) { - // Determine if it's a URL - if string.hasPrefix("http://") || string.hasPrefix("https://") || string.hasPrefix("file://") { - return ClipboardItem(content: string, type: .url) + // Priority 1: If there are file URLs AND the files are actual image files, treat as image files (from Finder) + if hasFileURLs { + if let fileURLs = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL] { + let realFileURLs = fileURLs.filter { url in + return url.isFileURL && + !url.path.contains("/.file/id=") && + !url.path.contains("/tmp/") && + !url.path.hasPrefix("/private/var/") && + !url.path.contains("/ClipboardViewer") && + FileManager.default.fileExists(atPath: url.path) + } + + if !realFileURLs.isEmpty { + // Check if these are image files - if so, try to load the actual image data + let imageExtensions = ["jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "heic", "heif"] + let imageFiles = realFileURLs.filter { url in + imageExtensions.contains(url.pathExtension.lowercased()) + } + + // If we have image files from Finder, load the actual image data + if !imageFiles.isEmpty, let firstImageURL = imageFiles.first { + if let imageData = try? Data(contentsOf: firstImageURL) { + return ClipboardItem(imageData: imageData) + } + } + + // Otherwise, treat as file(s) + let urlStrings = realFileURLs.map { $0.absoluteString } + return ClipboardItem(fileURLs: urlStrings) + } } - return ClipboardItem(content: string, type: .text) + } + + // Priority 2: If there's ONLY image data without file URLs (screenshots, direct image paste) + if hasImageData && !hasFileURLs { + if let imageData = pasteboard.data(forType: .png) { + return ClipboardItem(imageData: imageData) + } else if let imageData = pasteboard.data(forType: .tiff) { + return ClipboardItem(imageData: imageData) + } else if let imageData = pasteboard.data(forType: NSPasteboard.PasteboardType("public.jpeg")) { + return ClipboardItem(imageData: imageData) + } + } + + // Priority 3: Plain text (including copied text) + if let string = pasteboard.string(forType: .string), !string.isEmpty { + // Determine if it's a URL + if string.hasPrefix("http://") || string.hasPrefix("https://") { + return ClipboardItem(stringData: string, type: .url) + } + return ClipboardItem(stringData: string, type: .text) + } + + // Priority 4: RTF + if let rtfData = pasteboard.data(forType: .rtf), + let rtfString = NSAttributedString(rtf: rtfData, documentAttributes: nil)?.string, !rtfString.isEmpty { + return ClipboardItem(rtfData: rtfData, plainText: rtfString) + } + + // Priority 5: If we have image data WITH file URLs (document thumbnails), + // this is likely a document with a preview - ignore the thumbnail and treat as unknown + if hasImageData && hasFileURLs { + // This is likely a document with a thumbnail preview - we don't want the thumbnail + return nil + } + + // Priority 6: URL strings + if let url = pasteboard.string(forType: .URL) { + return ClipboardItem(stringData: url, type: .url) } return nil @@ -185,13 +355,35 @@ class ClipboardManager: ObservableObject { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - // Remove any existing items with the same content - self.clipboardHistory.removeAll { $0.content == item.content } + // Remove any existing items with the same data + let itemsToRemove = self.clipboardHistory.filter { existingItem in + return self.isSameContent(existingItem, item) + } + + // Clean up files for items being removed + for oldItem in itemsToRemove { + if let fileName = oldItem.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + + self.clipboardHistory.removeAll { existingItem in + return self.isSameContent(existingItem, item) + } // Add to beginning of array self.clipboardHistory.insert(item, at: 0) - // Keep only the most recent items + // Keep only the most recent items and clean up old files + let itemsToDelete = Array(self.clipboardHistory.dropFirst(self.maxHistoryItems)) + for oldItem in itemsToDelete { + if let fileName = oldItem.imageFileName { + let fileURL = ClipboardManager.clipboardDataDirectory.appendingPathComponent(fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + if self.clipboardHistory.count > self.maxHistoryItems { self.clipboardHistory = Array(self.clipboardHistory.prefix(self.maxHistoryItems)) } @@ -200,6 +392,39 @@ class ClipboardManager: ObservableObject { } } + // Helper to compare clipboard items for duplicates + private func isSameContent(_ item1: ClipboardItem, _ item2: ClipboardItem) -> Bool { + if item1.type != item2.type { return false } + + switch item1.type { + case .text, .url, .unknown: + return item1.stringData == item2.stringData + case .image: + // For images, compare the actual data if both are available + let data1 = item1.getImageData() + let data2 = item2.getImageData() + return data1 == data2 + case .file: + return item1.fileURLs == item2.fileURLs + case .rtf: + return item1.stringData == item2.stringData && item1.rtfData == item2.rtfData + } + } + + // Clean up old image files that are no longer referenced + private func cleanupOldFiles() { + guard let files = try? FileManager.default.contentsOfDirectory(at: ClipboardManager.clipboardDataDirectory, includingPropertiesForKeys: nil) else { return } + + let referencedFiles = Set(clipboardHistory.compactMap { $0.imageFileName }) + + for file in files { + let fileName = file.lastPathComponent + if !referencedFiles.contains(fileName) { + try? FileManager.default.removeItem(at: file) + } + } + } + // MARK: - Persistence private func saveHistoryToDefaults() { From 7137fcd255787b65a8132ef144c2a5ce715b9439 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 17 Jul 2025 22:47:04 +0530 Subject: [PATCH 4/4] BugFix: MouseOver and Add Feature: Clipboard settings --- DynamicIsland/DynamicIslandApp.swift | 35 +++++++++++++++++++ DynamicIsland/Localizable.xcstrings | 8 ++++- .../Notch/ClipboardHistoryPopover.swift | 13 +++++++ .../Notch/DynamicIslandHeader.swift | 8 ++++- .../components/Settings/SettingsView.swift | 8 ++++- DynamicIsland/managers/ClipboardManager.swift | 7 +++- DynamicIsland/models/Constants.swift | 1 + 7 files changed, 76 insertions(+), 4 deletions(-) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 40db3ca..ab73c2e 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -268,6 +268,41 @@ class AppDelegate: NSObject, NSApplicationDelegate { TimerManager.shared.startDemoTimer(duration: 300) } + KeyboardShortcuts.onKeyDown(for: .clipboardHistoryPanel) { [weak self] in + guard let self = self else { return } + + // 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() + } + + // Start clipboard monitoring if not already running + if !ClipboardManager.shared.isMonitoring { + ClipboardManager.shared.startMonitoring() + } + + // Post notification to trigger clipboard popover + NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil) + } + if !Defaults[.showOnAllDisplays] { let viewModel = self.vm let window = createDynamicIslandWindow( diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index e4984b9..19538a6 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -52,6 +52,9 @@ }, "5 items" : { + }, + "7 items" : { + }, "10 items" : { @@ -653,7 +656,7 @@ "Mirror shape" : { }, - "Monitor clipboard changes and keep a history of recent copies. The clipboard button will appear in the header next to the settings gear." : { + "Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history." : { }, "Monitoring Status" : { @@ -999,6 +1002,9 @@ }, "System Performance" : { + }, + "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" : { diff --git a/DynamicIsland/components/Notch/ClipboardHistoryPopover.swift b/DynamicIsland/components/Notch/ClipboardHistoryPopover.swift index 73dc37e..03f65b5 100644 --- a/DynamicIsland/components/Notch/ClipboardHistoryPopover.swift +++ b/DynamicIsland/components/Notch/ClipboardHistoryPopover.swift @@ -10,6 +10,7 @@ import SwiftUI struct ClipboardHistoryPopover: View { @ObservedObject var clipboardManager = ClipboardManager.shared @Binding var isPresented: Bool + @EnvironmentObject var vm: DynamicIslandViewModel var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -56,6 +57,18 @@ struct ClipboardHistoryPopover: View { .stroke(Color.gray.opacity(0.2), lineWidth: 1) ) .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) + .onHover { hovering in + // Close popover and notch when mouse exits + if !hovering { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + isPresented = false + // Also close the notch if no other popovers are active + if vm.notchState == .open && !vm.isBatteryPopoverActive { + vm.close() + } + } + } + } } @ViewBuilder diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 13115dd..4a0fbee 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -65,7 +65,7 @@ struct DynamicIslandHeader: View { .buttonStyle(PlainButtonStyle()) } - if Defaults[.enableClipboardManager] { + if Defaults[.enableClipboardManager] && Defaults[.showClipboardIcon] { Button(action: { showClipboardPopover.toggle() }) { @@ -138,6 +138,12 @@ struct DynamicIslandHeader: View { } .foregroundColor(.gray) .environmentObject(vm) + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ToggleClipboardPopover"))) { _ in + // Only toggle if clipboard is enabled + if Defaults[.enableClipboardManager] { + showClipboardPopover.toggle() + } + } } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index f261ec0..9c2e710 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -1309,6 +1309,7 @@ struct ClipboardSettings: View { @ObservedObject var clipboardManager = ClipboardManager.shared @Default(.enableClipboardManager) var enableClipboardManager @Default(.clipboardHistorySize) var clipboardHistorySize + @Default(.showClipboardIcon) var showClipboardIcon var body: some View { Form { @@ -1324,17 +1325,20 @@ struct ClipboardSettings: View { } header: { Text("Clipboard Manager") } footer: { - Text("Monitor clipboard changes and keep a history of recent copies. The clipboard button will appear in the header next to the settings gear.") + Text("Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history.") } if enableClipboardManager { Section { + Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon) + HStack { Text("History Size") Spacer() Picker("History Size", selection: $clipboardHistorySize) { Text("3 items").tag(3) Text("5 items").tag(5) + Text("7 items").tag(7) Text("10 items").tag(10) } .pickerStyle(.menu) @@ -1356,6 +1360,8 @@ 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.") } Section { diff --git a/DynamicIsland/managers/ClipboardManager.swift b/DynamicIsland/managers/ClipboardManager.swift index b6ede38..e4b4e7f 100644 --- a/DynamicIsland/managers/ClipboardManager.swift +++ b/DynamicIsland/managers/ClipboardManager.swift @@ -10,6 +10,7 @@ import SwiftUI import Combine import Foundation import UniformTypeIdentifiers +import Defaults // Clipboard item data structure struct ClipboardItem: Identifiable, Codable { @@ -152,7 +153,11 @@ class ClipboardManager: ObservableObject { private var timer: Timer? private var lastChangeCount: Int = 0 - private let maxHistoryItems = 3 + + // Use configurable history size from settings + private var maxHistoryItems: Int { + return Defaults[.clipboardHistorySize] + } // Directory for storing clipboard data files static let clipboardDataDirectory: URL = { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 120e444..e04f7e5 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -160,6 +160,7 @@ extension Defaults.Keys { // MARK: Clipboard Feature static let enableClipboardManager = Key("enableClipboardManager", default: true) static let clipboardHistorySize = Key("clipboardHistorySize", default: 3) + static let showClipboardIcon = Key("showClipboardIcon", default: true) // Helper to determine the default media controller based on macOS version static var defaultMediaController: MediaControllerType {