mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Add Popover
This commit is contained in:
parent
f4244eeb30
commit
8f0fc091a2
6 changed files with 400 additions and 13 deletions
|
|
@ -450,8 +450,25 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
|||
ClipboardManager.shared.startMonitoring()
|
||||
}
|
||||
|
||||
// Always use panel mode
|
||||
ClipboardPanelManager.shared.toggleClipboardPanel()
|
||||
// Handle keyboard shortcut based on display mode
|
||||
switch Defaults[.clipboardDisplayMode] {
|
||||
case .panel:
|
||||
ClipboardPanelManager.shared.toggleClipboardPanel()
|
||||
case .popover:
|
||||
// For popover mode, first ensure notch is open, then toggle popover
|
||||
|
||||
// If notch is closed, open it first
|
||||
if self.vm.notchState == .closed {
|
||||
self.vm.open()
|
||||
// Wait a moment for the notch to open, then show popover
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
||||
}
|
||||
} else {
|
||||
// Notch is already open, toggle popover immediately
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ToggleClipboardPopover"), object: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
KeyboardShortcuts.onKeyDown(for: .statsPanel) { [weak self] in
|
||||
|
|
|
|||
|
|
@ -139,9 +139,6 @@
|
|||
},
|
||||
"Appearance" : {
|
||||
|
||||
},
|
||||
"Auto mode intelligently chooses between popover (desktop) and window (fullscreen apps) for the best experience. You can override this with manual selection." : {
|
||||
|
||||
},
|
||||
"Automatically check for updates" : {
|
||||
"localizations" : {
|
||||
|
|
@ -323,6 +320,9 @@
|
|||
},
|
||||
"Copy something to get started" : {
|
||||
|
||||
},
|
||||
"Copy something to start" : {
|
||||
|
||||
},
|
||||
"CPU Usage" : {
|
||||
|
||||
|
|
@ -394,6 +394,9 @@
|
|||
},
|
||||
"Disk Write" : {
|
||||
|
||||
},
|
||||
"Display Mode" : {
|
||||
|
||||
},
|
||||
"Download" : {
|
||||
|
||||
|
|
@ -810,6 +813,9 @@
|
|||
},
|
||||
"No Graphs Enabled" : {
|
||||
|
||||
},
|
||||
"No results" : {
|
||||
|
||||
},
|
||||
"No results found" : {
|
||||
|
||||
|
|
@ -869,6 +875,9 @@
|
|||
},
|
||||
"Opens the detailed system performance monitor panel. Default is Cmd+Shift+S. Only works when stats feature is enabled." : {
|
||||
|
||||
},
|
||||
"Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button." : {
|
||||
|
||||
},
|
||||
"Pause" : {
|
||||
|
||||
|
|
@ -888,6 +897,9 @@
|
|||
},
|
||||
"Pin items from history to save them here" : {
|
||||
|
||||
},
|
||||
"Pin items to add favorites" : {
|
||||
|
||||
},
|
||||
"Pin items to add them to favorites" : {
|
||||
|
||||
|
|
@ -897,6 +909,9 @@
|
|||
},
|
||||
"Plugged In" : {
|
||||
|
||||
},
|
||||
"Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch." : {
|
||||
|
||||
},
|
||||
"Progress bar" : {
|
||||
"localizations" : {
|
||||
|
|
@ -965,6 +980,9 @@
|
|||
},
|
||||
"Search clipboard..." : {
|
||||
|
||||
},
|
||||
"Search..." : {
|
||||
|
||||
},
|
||||
"seconds" : {
|
||||
"extractionState" : "stale",
|
||||
|
|
@ -1185,6 +1203,9 @@
|
|||
},
|
||||
"Try adjusting your search terms" : {
|
||||
|
||||
},
|
||||
"Try different 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" : {
|
||||
|
||||
|
|
|
|||
308
DynamicIsland/components/Clipboard/ClipboardPopover.swift
Normal file
308
DynamicIsland/components/Clipboard/ClipboardPopover.swift
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
//
|
||||
// ClipboardPopover.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 13/08/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct ClipboardPopover: View {
|
||||
@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
|
||||
ClipboardPopoverHeader(
|
||||
selectedTab: $selectedTab,
|
||||
searchText: $searchText
|
||||
)
|
||||
|
||||
Divider()
|
||||
.background(Color.gray.opacity(0.3))
|
||||
|
||||
// Content
|
||||
if filteredItems.isEmpty {
|
||||
ClipboardPopoverEmptyState(
|
||||
hasSearch: !searchText.isEmpty,
|
||||
isHistoryTab: selectedTab == .history
|
||||
)
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 1) {
|
||||
ForEach(filteredItems) { item in
|
||||
ClipboardPopoverItemRow(
|
||||
item: item,
|
||||
isHovered: hoveredItemId == item.id,
|
||||
isPinned: clipboardManager.pinnedItems.contains(where: { $0.id == item.id })
|
||||
) { hoverId in
|
||||
hoveredItemId = hoverId
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 280, height: 320)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(.regularMaterial)
|
||||
.shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 4)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct ClipboardPopoverHeader: View {
|
||||
@Binding var selectedTab: ClipboardTab
|
||||
@Binding var searchText: String
|
||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||
@FocusState private var isSearchFieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
// Title
|
||||
HStack {
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.foregroundColor(.primary)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
|
||||
Text("Clipboard")
|
||||
.font(.system(size: 13, 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: 11))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(selectedTab == .history ? clipboardManager.clipboardHistory.isEmpty : clipboardManager.pinnedItems.isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 10)
|
||||
|
||||
// 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, 14)
|
||||
|
||||
// Search bar
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.system(size: 11))
|
||||
|
||||
TextField("Search...", text: $searchText)
|
||||
.textFieldStyle(PlainTextFieldStyle())
|
||||
.font(.system(size: 11))
|
||||
.focused($isSearchFieldFocused)
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button(action: { searchText = "" }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.system(size: 9))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(Color.gray.opacity(0.1))
|
||||
)
|
||||
.padding(.horizontal, 14)
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
|
||||
struct ClipboardPopoverEmptyState: View {
|
||||
let hasSearch: Bool
|
||||
let isHistoryTab: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: hasSearch ? "magnifyingglass" : (isHistoryTab ? "doc.on.clipboard" : "heart.fill"))
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
if hasSearch {
|
||||
Text("No results")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Try different search terms")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text(isHistoryTab ? "No clipboard history" : "No favorites")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(isHistoryTab ? "Copy something to start" : "Pin items to add favorites")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding(20)
|
||||
}
|
||||
}
|
||||
|
||||
struct ClipboardPopoverItemRow: 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: 10) {
|
||||
// Type icon
|
||||
Image(systemName: item.type.icon)
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.blue)
|
||||
.frame(width: 16)
|
||||
|
||||
// Content
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(item.preview)
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
HStack {
|
||||
Text(item.type.displayName)
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(timeAgoString(from: item.timestamp))
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Action buttons (shown on hover)
|
||||
if isHovered {
|
||||
HStack(spacing: 4) {
|
||||
// Pin/Unpin button
|
||||
Button(action: {
|
||||
if isPinned {
|
||||
clipboardManager.unpinItem(item)
|
||||
} else {
|
||||
clipboardManager.pinItem(item)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: isPinned ? "heart.fill" : "heart")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(isPinned ? .red : .gray)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
// Copy button
|
||||
Button(action: {
|
||||
clipboardManager.copyToClipboard(item)
|
||||
}) {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
// Delete button
|
||||
Button(action: {
|
||||
if isPinned {
|
||||
clipboardManager.unpinItem(item)
|
||||
} else {
|
||||
clipboardManager.deleteItem(item)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 9))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(isHovered ? Color.gray.opacity(0.1) : Color.clear)
|
||||
)
|
||||
.onHover { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
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 "now"
|
||||
} else if interval < 3600 {
|
||||
let minutes = Int(interval / 60)
|
||||
return "\(minutes)m"
|
||||
} else if interval < 86400 {
|
||||
let hours = Int(interval / 3600)
|
||||
return "\(hours)h"
|
||||
} else {
|
||||
let days = Int(interval / 86400)
|
||||
return "\(days)d"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ClipboardPopover()
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ struct DynamicIslandHeader: View {
|
|||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@ObservedObject var clipboardManager = ClipboardManager.shared
|
||||
@StateObject var tvm = TrayDrop.shared
|
||||
@State private var showClipboardPopover = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
|
|
@ -62,8 +63,13 @@ struct DynamicIslandHeader: View {
|
|||
|
||||
if Defaults[.enableClipboardManager] && Defaults[.showClipboardIcon] {
|
||||
Button(action: {
|
||||
// Always use panel mode
|
||||
ClipboardPanelManager.shared.toggleClipboardPanel()
|
||||
// Switch behavior based on display mode
|
||||
switch Defaults[.clipboardDisplayMode] {
|
||||
case .panel:
|
||||
ClipboardPanelManager.shared.toggleClipboardPanel()
|
||||
case .popover:
|
||||
showClipboardPopover.toggle()
|
||||
}
|
||||
}) {
|
||||
Capsule()
|
||||
.fill(.black)
|
||||
|
|
@ -76,6 +82,9 @@ struct DynamicIslandHeader: View {
|
|||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.popover(isPresented: $showClipboardPopover, arrowEdge: .bottom) {
|
||||
ClipboardPopover()
|
||||
}
|
||||
.onAppear {
|
||||
if Defaults[.enableClipboardManager] && !clipboardManager.isMonitoring {
|
||||
clipboardManager.startMonitoring()
|
||||
|
|
@ -125,7 +134,18 @@ struct DynamicIslandHeader: View {
|
|||
.onChange(of: coordinator.shouldToggleClipboardPopover) { _ in
|
||||
// Only toggle if clipboard is enabled
|
||||
if Defaults[.enableClipboardManager] {
|
||||
ClipboardPanelManager.shared.toggleClipboardPanel()
|
||||
switch Defaults[.clipboardDisplayMode] {
|
||||
case .panel:
|
||||
ClipboardPanelManager.shared.toggleClipboardPanel()
|
||||
case .popover:
|
||||
showClipboardPopover.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ToggleClipboardPopover"))) { _ in
|
||||
// Handle keyboard shortcut for popover mode
|
||||
if Defaults[.enableClipboardManager] && Defaults[.clipboardDisplayMode] == .popover {
|
||||
showClipboardPopover.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1630,6 +1630,7 @@ struct ClipboardSettings: View {
|
|||
@Default(.enableClipboardManager) var enableClipboardManager
|
||||
@Default(.clipboardHistorySize) var clipboardHistorySize
|
||||
@Default(.showClipboardIcon) var showClipboardIcon
|
||||
@Default(.clipboardDisplayMode) var clipboardDisplayMode
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
|
@ -1652,6 +1653,18 @@ struct ClipboardSettings: View {
|
|||
Section {
|
||||
Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon)
|
||||
|
||||
HStack {
|
||||
Text("Display Mode")
|
||||
Spacer()
|
||||
Picker("Display Mode", selection: $clipboardDisplayMode) {
|
||||
ForEach(ClipboardDisplayMode.allCases, id: \.self) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 100)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("History Size")
|
||||
Spacer()
|
||||
|
|
@ -1688,7 +1701,12 @@ struct ClipboardSettings: View {
|
|||
} header: {
|
||||
Text("Settings")
|
||||
} footer: {
|
||||
Text("Auto mode intelligently chooses between popover (desktop) and window (fullscreen apps) for the best experience. You can override this with manual selection.")
|
||||
switch clipboardDisplayMode {
|
||||
case .popover:
|
||||
Text("Popover mode shows clipboard as a dropdown attached to the clipboard button. Panel mode shows clipboard in a floating window near the notch.")
|
||||
case .panel:
|
||||
Text("Panel mode shows clipboard in a floating window near the notch. Popover mode shows clipboard as a dropdown attached to the clipboard button.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
|
|
|
|||
|
|
@ -31,17 +31,20 @@ enum CalendarSelectionState: Codable, Defaults.Serializable {
|
|||
}
|
||||
|
||||
enum ClipboardDisplayMode: String, CaseIterable, Codable, Defaults.Serializable {
|
||||
case popover = "popover" // Always use floating panel
|
||||
case popover = "popover" // Traditional popover attached to button
|
||||
case panel = "panel" // Floating panel near notch
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .popover: return "Panel"
|
||||
case .popover: return "Popover"
|
||||
case .panel: return "Panel"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .popover: return "Shows clipboard in a floating panel near the notch"
|
||||
case .popover: return "Shows clipboard as a dropdown attached to the clipboard button"
|
||||
case .panel: return "Shows clipboard in a floating panel near the notch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -187,7 +190,7 @@ extension Defaults.Keys {
|
|||
static let enableClipboardManager = Key<Bool>("enableClipboardManager", default: true)
|
||||
static let clipboardHistorySize = Key<Int>("clipboardHistorySize", default: 3)
|
||||
static let showClipboardIcon = Key<Bool>("showClipboardIcon", default: true)
|
||||
static let clipboardDisplayMode = Key<ClipboardDisplayMode>("clipboardDisplayMode", default: .popover)
|
||||
static let clipboardDisplayMode = Key<ClipboardDisplayMode>("clipboardDisplayMode", default: .panel)
|
||||
|
||||
// MARK: Keyboard Shortcuts
|
||||
static let enableShortcuts = Key<Bool>("enableShortcuts", default: true)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue