Merge pull request #14 from Ebullioscopic/feature/clipboard

Feature/clipboard
This commit is contained in:
Hariharan Mudaliar 2025-08-12 23:38:41 +05:30 • committed by GitHub
commit 85c7785851
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1101 additions and 63 deletions

View file

@ -124,7 +124,7 @@ struct ContentView: View {
} }
// Only close if mouse leaves and the notch is open and no popovers are active // 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() 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) .sensoryFeedback(.alignment, trigger: haptics)
.contextMenu { .contextMenu {
SettingsLink(label: { SettingsLink(label: {
@ -524,7 +517,7 @@ struct ContentView: View {
} }
// Close the notch if it's open and no popovers are active // 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() vm.close()
} }
} }

View file

@ -445,38 +445,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Only open clipboard if the feature is enabled // Only open clipboard if the feature is enabled
guard Defaults[.enableClipboardManager] else { return } 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 // Start clipboard monitoring if not already running
if !ClipboardManager.shared.isMonitoring { if !ClipboardManager.shared.isMonitoring {
ClipboardManager.shared.startMonitoring() ClipboardManager.shared.startMonitoring()
} }
// Always use panel mode
ClipboardPanelManager.shared.toggleClipboardPanel()
} }
if !Defaults[.showOnAllDisplays] { if !Defaults[.showOnAllDisplays] {
@ -615,6 +590,8 @@ class AppDelegate: NSObject, NSApplicationDelegate {
NSApplication.shared.terminate(nil) NSApplication.shared.terminate(nil)
} }
private func showOnboardingWindow() { private func showOnboardingWindow() {
if onboardingWindowController == nil { if onboardingWindowController == nil {
let window = NSWindow( let window = NSWindow(

View file

@ -136,6 +136,9 @@
}, },
"Appearance" : { "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" : { "Automatically check for updates" : {
"localizations" : { "localizations" : {
@ -271,9 +274,15 @@
}, },
"Clear Clipboard History" : { "Clear Clipboard History" : {
},
"Clear clipboard history removes recent copies. Clear pinned items removes your favorites. Both actions are permanent." : {
}, },
"Clear Data" : { "Clear Data" : {
},
"Clear Pinned Items" : {
}, },
"Clipboard" : { "Clipboard" : {
@ -783,9 +792,15 @@
}, },
"No extension installed" : { "No extension installed" : {
},
"No favorites" : {
}, },
"No Graphs Enabled" : { "No Graphs Enabled" : {
},
"No results found" : {
}, },
"Non-notch display height" : { "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" : { "Plugged In" : {
@ -920,6 +944,9 @@
}, },
"Running" : { "Running" : {
},
"Search clipboard..." : {
}, },
"seconds" : { "seconds" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -1083,15 +1110,9 @@
}, },
"System features" : { "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" : { "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" : { "Time to Full Charge: %lld min" : {
@ -1122,6 +1143,9 @@
}, },
"Transform your notch truly yours" : { "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" : { "Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled" : {

View file

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

View file

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

View file

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

View file

@ -15,7 +15,6 @@ struct DynamicIslandHeader: View {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ObservedObject var clipboardManager = ClipboardManager.shared @ObservedObject var clipboardManager = ClipboardManager.shared
@StateObject var tvm = TrayDrop.shared @StateObject var tvm = TrayDrop.shared
@State private var showClipboardPopover = false
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
@ -63,7 +62,8 @@ struct DynamicIslandHeader: View {
if Defaults[.enableClipboardManager] && Defaults[.showClipboardIcon] { if Defaults[.enableClipboardManager] && Defaults[.showClipboardIcon] {
Button(action: { Button(action: {
showClipboardPopover.toggle() // Always use panel mode
ClipboardPanelManager.shared.toggleClipboardPanel()
}) { }) {
Capsule() Capsule()
.fill(.black) .fill(.black)
@ -76,18 +76,6 @@ struct DynamicIslandHeader: View {
} }
} }
.buttonStyle(PlainButtonStyle()) .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 { .onAppear {
if Defaults[.enableClipboardManager] && !clipboardManager.isMonitoring { if Defaults[.enableClipboardManager] && !clipboardManager.isMonitoring {
clipboardManager.startMonitoring() clipboardManager.startMonitoring()
@ -137,7 +125,7 @@ struct DynamicIslandHeader: View {
.onChange(of: coordinator.shouldToggleClipboardPopover) { _ in .onChange(of: coordinator.shouldToggleClipboardPopover) { _ in
// Only toggle if clipboard is enabled // Only toggle if clipboard is enabled
if Defaults[.enableClipboardManager] { if Defaults[.enableClipboardManager] {
showClipboardPopover.toggle() ClipboardPanelManager.shared.toggleClipboardPanel()
} }
} }
} }

View file

@ -41,10 +41,10 @@ class DynamicIslandWindow: NSPanel {
} }
override var canBecomeKey: Bool { override var canBecomeKey: Bool {
false true
} }
override var canBecomeMain: Bool { override var canBecomeMain: Bool {
false true
} }
} }

View file

@ -1631,6 +1631,13 @@ struct ClipboardSettings: View {
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
HStack {
Text("Pinned Items")
Spacer()
Text("\(clipboardManager.pinnedItems.count)")
.foregroundColor(.secondary)
}
HStack { HStack {
Text("Monitoring Status") Text("Monitoring Status")
Spacer() Spacer()
@ -1640,7 +1647,7 @@ struct ClipboardSettings: View {
} header: { } header: {
Text("Settings") Text("Settings")
} footer: { } 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 { Section {
@ -1649,10 +1656,17 @@ struct ClipboardSettings: View {
} }
.foregroundColor(.red) .foregroundColor(.red)
.disabled(clipboardManager.clipboardHistory.isEmpty) .disabled(clipboardManager.clipboardHistory.isEmpty)
Button("Clear Pinned Items") {
clipboardManager.pinnedItems.removeAll()
clipboardManager.savePinnedItemsToDefaults()
}
.foregroundColor(.red)
.disabled(clipboardManager.pinnedItems.isEmpty)
} header: { } header: {
Text("Actions") Text("Actions")
} footer: { } 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 { if !clipboardManager.clipboardHistory.isEmpty {

View file

@ -18,6 +18,7 @@ struct ClipboardItem: Identifiable, Codable {
let type: ClipboardItemType let type: ClipboardItemType
let timestamp: Date let timestamp: Date
let preview: String let preview: String
var isPinned: Bool = false
// Store different types of data - avoid large binary data in UserDefaults // Store different types of data - avoid large binary data in UserDefaults
let stringData: String? let stringData: String?
@ -90,6 +91,14 @@ struct ClipboardItem: Identifiable, Codable {
return try? Data(contentsOf: fileURL) 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 { static func generatePreview(stringData: String, type: ClipboardItemType) -> String {
switch type { switch type {
case .text: case .text:
@ -149,6 +158,7 @@ class ClipboardManager: ObservableObject {
static let shared = ClipboardManager() static let shared = ClipboardManager()
@Published var clipboardHistory: [ClipboardItem] = [] @Published var clipboardHistory: [ClipboardItem] = []
@Published var pinnedItems: [ClipboardItem] = []
@Published var isMonitoring: Bool = false @Published var isMonitoring: Bool = false
private var timer: Timer? private var timer: Timer?
@ -159,6 +169,15 @@ class ClipboardManager: ObservableObject {
return Defaults[.clipboardHistorySize] 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 // Directory for storing clipboard data files
static let clipboardDataDirectory: URL = { static let clipboardDataDirectory: URL = {
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
@ -254,6 +273,58 @@ class ClipboardManager: ObservableObject {
saveHistoryToDefaults() 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 // MARK: - Private Methods
private func checkClipboard() { 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() { private func loadHistoryFromDefaults() {
if let data = UserDefaults.standard.data(forKey: "ClipboardHistory"), if let data = UserDefaults.standard.data(forKey: "ClipboardHistory"),
let history = try? JSONDecoder().decode([ClipboardItem].self, from: data) { let history = try? JSONDecoder().decode([ClipboardItem].self, from: data) {
clipboardHistory = history clipboardHistory = history
} }
if let data = UserDefaults.standard.data(forKey: "ClipboardPinnedItems"),
let pinned = try? JSONDecoder().decode([ClipboardItem].self, from: data) {
pinnedItems = pinned
}
} }
} }

View file

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

View file

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

View file

@ -30,6 +30,22 @@ enum CalendarSelectionState: Codable, Defaults.Serializable {
case selected(Set<String>) case selected(Set<String>)
} }
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 { enum HideNotchOption: String, Defaults.Serializable {
case always case always
case nowPlayingOnly case nowPlayingOnly
@ -170,6 +186,7 @@ extension Defaults.Keys {
static let enableClipboardManager = Key<Bool>("enableClipboardManager", default: true) static let enableClipboardManager = Key<Bool>("enableClipboardManager", default: true)
static let clipboardHistorySize = Key<Int>("clipboardHistorySize", default: 3) static let clipboardHistorySize = Key<Int>("clipboardHistorySize", default: 3)
static let showClipboardIcon = Key<Bool>("showClipboardIcon", default: true) static let showClipboardIcon = Key<Bool>("showClipboardIcon", default: true)
static let clipboardDisplayMode = Key<ClipboardDisplayMode>("clipboardDisplayMode", default: .popover)
// MARK: Keyboard Shortcuts // MARK: Keyboard Shortcuts
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true) static let enableShortcuts = Key<Bool>("enableShortcuts", default: true)

View file

@ -29,7 +29,6 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
@Published var hideOnClosed: Bool = true @Published var hideOnClosed: Bool = true
@Published var isHoveringCalendar: Bool = false @Published var isHoveringCalendar: Bool = false
@Published var isBatteryPopoverActive: Bool = false @Published var isBatteryPopoverActive: Bool = false
@Published var isClipboardPopoverActive: Bool = false
let webcamManager = WebcamManager.shared let webcamManager = WebcamManager.shared
@Published var isCameraExpanded: Bool = false @Published var isCameraExpanded: Bool = false