From 4890e6fc28f6d420ed3d0b70640492cf65c60b96 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 17 Jul 2025 20:21:49 +0530 Subject: [PATCH] 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?