Working Text Clipboard

This commit is contained in:
Hariharan Mudaliar 2025-07-17 20:21:49 +05:30
commit 4890e6fc28
8 changed files with 631 additions and 4 deletions

View file

@ -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()
}
}

View file

@ -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" : {

View file

@ -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)
}

View file

@ -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()

View file

@ -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()
}

View file

@ -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
}
}
}

View file

@ -157,6 +157,10 @@ extension Defaults.Keys {
// MARK: Stats Feature
static let enableStatsFeature = Key<Bool>("enableStatsFeature", default: false)
// MARK: Clipboard Feature
static let enableClipboardManager = Key<Bool>("enableClipboardManager", default: true)
static let clipboardHistorySize = Key<Int>("clipboardHistorySize", default: 3)
// Helper to determine the default media controller based on macOS version
static var defaultMediaController: MediaControllerType {
if #available(macOS 15.4, *) {

View file

@ -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?