mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Fully working colorpicker
This commit is contained in:
parent
cc5667d355
commit
6a8477758b
16 changed files with 1668 additions and 1 deletions
|
|
@ -0,0 +1,66 @@
|
|||
//
|
||||
// ColorPickedFeedbackView.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 14/08/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct ColorPickedFeedbackView: View {
|
||||
let color: PickedColor
|
||||
@Binding var isShowing: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Color preview
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(color.color)
|
||||
.frame(width: 60, height: 60)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.white.opacity(0.8), lineWidth: 2)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.3), radius: 8)
|
||||
|
||||
// Color info
|
||||
VStack(spacing: 4) {
|
||||
Text("Color Picked!")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(color.hexString)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
|
||||
.cornerRadius(16)
|
||||
.shadow(color: .black.opacity(0.3), radius: 12)
|
||||
.scaleEffect(isShowing ? 1.0 : 0.8)
|
||||
.opacity(isShowing ? 1.0 : 0.0)
|
||||
.animation(.spring(response: 0.4, dampingFraction: 0.7), value: isShowing)
|
||||
.onTapGesture {
|
||||
// Copy to clipboard on tap
|
||||
ColorPickerManager.shared.copyToClipboard(color.hexString)
|
||||
|
||||
if Defaults[.enableHaptics] {
|
||||
NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ColorPickedFeedbackView(
|
||||
color: PickedColor(nsColor: NSColor.blue, point: CGPoint(x: 100, y: 100)),
|
||||
isShowing: .constant(true)
|
||||
)
|
||||
.frame(width: 300, height: 200)
|
||||
}
|
||||
612
DynamicIsland/components/ColorPicker/ColorPickerPanel.swift
Normal file
612
DynamicIsland/components/ColorPicker/ColorPickerPanel.swift
Normal file
|
|
@ -0,0 +1,612 @@
|
|||
//
|
||||
// ColorPickerPanel.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 14/08/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Cocoa
|
||||
import Defaults
|
||||
|
||||
class ColorPickerPanelManager: ObservableObject {
|
||||
static let shared = ColorPickerPanelManager()
|
||||
|
||||
private var panel: ColorPickerPanel?
|
||||
|
||||
private init() {}
|
||||
|
||||
func showColorPickerPanel() {
|
||||
hideColorPickerPanel() // Close any existing panel
|
||||
|
||||
let newPanel = ColorPickerPanel()
|
||||
newPanel.positionPanel()
|
||||
|
||||
self.panel = newPanel
|
||||
|
||||
// Make the panel key and order front to ensure it can receive focus
|
||||
newPanel.makeKeyAndOrderFront(nil)
|
||||
newPanel.orderFrontRegardless()
|
||||
|
||||
// Activate the app to ensure proper focus handling
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
// Ensure the panel becomes the key window for input
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
newPanel.makeKey()
|
||||
}
|
||||
|
||||
print("ColorPicker: Panel shown and positioned")
|
||||
}
|
||||
|
||||
func hideColorPickerPanel() {
|
||||
panel?.close()
|
||||
panel = nil
|
||||
print("ColorPicker: Panel hidden")
|
||||
}
|
||||
|
||||
func toggleColorPickerPanel() {
|
||||
if let panel = panel, panel.isVisible {
|
||||
hideColorPickerPanel()
|
||||
} else {
|
||||
showColorPickerPanel()
|
||||
}
|
||||
}
|
||||
|
||||
var isPanelVisible: Bool {
|
||||
return panel?.isVisible ?? false
|
||||
}
|
||||
}
|
||||
|
||||
class ColorPickerPanel: NSPanel {
|
||||
override var canBecomeKey: Bool { true }
|
||||
override var canBecomeMain: Bool { false }
|
||||
|
||||
init() {
|
||||
super.init(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 450, height: 600),
|
||||
styleMask: [.titled, .closable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
setupPanel()
|
||||
}
|
||||
|
||||
private func setupPanel() {
|
||||
title = "Color Picker"
|
||||
level = .floating
|
||||
isMovableByWindowBackground = true
|
||||
backgroundColor = NSColor.clear
|
||||
isOpaque = false
|
||||
hasShadow = true
|
||||
|
||||
titlebarAppearsTransparent = true
|
||||
titleVisibility = .hidden
|
||||
|
||||
let panelView = ColorPickerPanelView {
|
||||
ColorPickerPanelManager.shared.hideColorPickerPanel()
|
||||
}
|
||||
|
||||
contentView = NSHostingView(rootView: panelView)
|
||||
}
|
||||
|
||||
func positionPanel() {
|
||||
guard let screen = NSScreen.main else { return }
|
||||
|
||||
let screenFrame = screen.visibleFrame
|
||||
let panelFrame = frame
|
||||
|
||||
let xPosition = screenFrame.midX - panelFrame.width / 2
|
||||
let yPosition = screenFrame.midY - panelFrame.height / 2
|
||||
|
||||
setFrameOrigin(NSPoint(x: xPosition, y: yPosition))
|
||||
print("ColorPicker: Panel positioned at \(xPosition), \(yPosition)")
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorPickerPanelView: View {
|
||||
let onClose: () -> Void
|
||||
@ObservedObject var colorPickerManager = ColorPickerManager.shared
|
||||
@State private var searchText = ""
|
||||
@State private var selectedColor: PickedColor?
|
||||
@State private var showingDeleteAlert = false
|
||||
@State private var colorToDelete: PickedColor?
|
||||
|
||||
var filteredColors: [PickedColor] {
|
||||
if searchText.isEmpty {
|
||||
return colorPickerManager.colorHistory
|
||||
} else {
|
||||
return colorPickerManager.colorHistory.filter { color in
|
||||
color.hexString.localizedCaseInsensitiveContains(searchText) ||
|
||||
color.rgbString.localizedCaseInsensitiveContains(searchText) ||
|
||||
color.hslString.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
headerSection
|
||||
|
||||
Divider()
|
||||
.background(Color.gray.opacity(0.3))
|
||||
|
||||
contentSection
|
||||
}
|
||||
.background(ColorPickerVisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
|
||||
.cornerRadius(12)
|
||||
.alert("Delete Color", isPresented: $showingDeleteAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Delete", role: .destructive) {
|
||||
if let color = colorToDelete {
|
||||
colorPickerManager.removeColor(color)
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete this color from your history?")
|
||||
}
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
// Title and controls
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "eyedropper.halffull")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Color Picker")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Controls
|
||||
HStack(spacing: 8) {
|
||||
// Pick Color Button
|
||||
Button(action: {
|
||||
colorPickerManager.startColorPicking()
|
||||
onClose() // Close panel when starting to pick
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Text("Pick Color")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
// Clear History Button
|
||||
Button(action: {
|
||||
colorPickerManager.clearHistory()
|
||||
}) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.disabled(colorPickerManager.colorHistory.isEmpty)
|
||||
|
||||
// Close Button
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
// Search bar
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.system(size: 14))
|
||||
|
||||
TextField("Search colors...", text: $searchText)
|
||||
.textFieldStyle(PlainTextFieldStyle())
|
||||
.font(.system(size: 14))
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button(action: { searchText = "" }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
private var contentSection: some View {
|
||||
HSplitView {
|
||||
// Left side - Color list
|
||||
colorListSection
|
||||
.frame(minWidth: 200)
|
||||
|
||||
// Right side - Color details
|
||||
colorDetailsSection
|
||||
.frame(minWidth: 220)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var colorListSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Recent Colors")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(filteredColors.count)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.gray.opacity(0.05))
|
||||
|
||||
Divider()
|
||||
.background(Color.gray.opacity(0.3))
|
||||
|
||||
// Color list
|
||||
if filteredColors.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(filteredColors) { color in
|
||||
ColorListRow(
|
||||
color: color,
|
||||
isSelected: selectedColor?.id == color.id,
|
||||
onSelect: { selectedColor = color },
|
||||
onDelete: {
|
||||
colorToDelete = color
|
||||
showingDeleteAlert = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var colorDetailsSection: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Color Details")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.gray.opacity(0.05))
|
||||
|
||||
Divider()
|
||||
.background(Color.gray.opacity(0.3))
|
||||
|
||||
// Details content
|
||||
if let color = selectedColor {
|
||||
ColorDetailsView(color: color)
|
||||
} else {
|
||||
noSelectionView
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "eyedropper")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("No Colors Found")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
if searchText.isEmpty {
|
||||
Text("Start picking colors to build your history")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
} else {
|
||||
Text("No colors match '\(searchText)'")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private var noSelectionView: some View {
|
||||
VStack(spacing: 16) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "hand.point.up.left")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("Select a color to view details")
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorListRow: View {
|
||||
let color: PickedColor
|
||||
let isSelected: Bool
|
||||
let onSelect: () -> Void
|
||||
let onDelete: () -> Void
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Color preview
|
||||
Circle()
|
||||
.fill(color.color)
|
||||
.frame(width: 32, height: 32)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.8), lineWidth: 1)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.black.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
|
||||
// Color info
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(color.hexString)
|
||||
.font(.system(size: 14, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(timeAgoString(from: color.timestamp))
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Actions (shown on hover)
|
||||
if isHovered {
|
||||
HStack(spacing: 4) {
|
||||
Button(action: {
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(color.hexString, forType: .string)
|
||||
|
||||
if Defaults[.enableHaptics] {
|
||||
NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default)
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
Button(action: onDelete) {
|
||||
Image(systemName: "trash")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Rectangle()
|
||||
.fill(isSelected ? Color.blue.opacity(0.2) : (isHovered ? Color.gray.opacity(0.1) : Color.clear))
|
||||
)
|
||||
.onHover { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isHovered = hovering
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
onSelect()
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorDetailsView: View {
|
||||
let color: PickedColor
|
||||
@Default(.showColorFormats) var showColorFormats
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Large color preview
|
||||
VStack(spacing: 12) {
|
||||
Rectangle()
|
||||
.fill(color.color)
|
||||
.frame(height: 80)
|
||||
.overlay(
|
||||
Rectangle()
|
||||
.stroke(Color.white.opacity(0.8), lineWidth: 2)
|
||||
)
|
||||
.cornerRadius(8)
|
||||
.shadow(color: .black.opacity(0.2), radius: 4)
|
||||
|
||||
Text("Picked \(timeAgoString(from: color.timestamp))")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
// Color formats
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Color Formats")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
ForEach(color.allFormats) { format in
|
||||
ColorFormatDetailRow(format: format, color: color)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
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) minute\(minutes == 1 ? "" : "s") ago"
|
||||
} else if interval < 86400 {
|
||||
let hours = Int(interval / 3600)
|
||||
return "\(hours) hour\(hours == 1 ? "" : "s") ago"
|
||||
} else {
|
||||
let days = Int(interval / 86400)
|
||||
return "\(days) day\(days == 1 ? "" : "s") ago"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorFormatDetailRow: View {
|
||||
let format: ColorFormat
|
||||
let color: PickedColor
|
||||
@State private var isHovered = false
|
||||
@State private var justCopied = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(format.name)
|
||||
.font(.system(size: 12, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 60, alignment: .leading)
|
||||
|
||||
Text(format.value)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button(action: copyFormat) {
|
||||
Image(systemName: justCopied ? "checkmark.circle.fill" : "doc.on.doc")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(justCopied ? .green : .blue)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.opacity(isHovered || justCopied ? 1 : 0)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(isHovered ? Color.white.opacity(0.1) : Color.clear)
|
||||
)
|
||||
.onHover { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isHovered = hovering
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
copyFormat()
|
||||
}
|
||||
}
|
||||
|
||||
private func copyFormat() {
|
||||
ColorPickerManager.shared.copyColorToClipboard(color, format: format)
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
justCopied = true
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
justCopied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper view for visual effects
|
||||
struct ColorPickerVisualEffectView: NSViewRepresentable {
|
||||
let material: NSVisualEffectView.Material
|
||||
let blendingMode: NSVisualEffectView.BlendingMode
|
||||
|
||||
func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
let view = NSVisualEffectView()
|
||||
view.material = material
|
||||
view.blendingMode = blendingMode
|
||||
view.state = .active
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: NSVisualEffectView, context: Context) {}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ColorPickerPanelView {
|
||||
print("Close panel")
|
||||
}
|
||||
.frame(width: 450, height: 600)
|
||||
.onAppear {
|
||||
ColorPickerManager.shared.colorHistory = PickedColor.sampleColors
|
||||
}
|
||||
}
|
||||
130
DynamicIsland/components/ColorPicker/ColorPickerPopover.swift
Normal file
130
DynamicIsland/components/ColorPicker/ColorPickerPopover.swift
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
//
|
||||
// ColorPickerPopover.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 14/08/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct ColorPickerPopover: View {
|
||||
@ObservedObject var colorPickerManager = ColorPickerManager.shared
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Header
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "eyedropper.halffull")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Color Picker")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: {
|
||||
dismiss()
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
// Main actions
|
||||
VStack(spacing: 12) {
|
||||
// Pick Color Button
|
||||
Button(action: {
|
||||
colorPickerManager.startColorPicking()
|
||||
dismiss() // Close popover when starting to pick
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Text("Pick Color")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
// Show Panel Button
|
||||
Button(action: {
|
||||
ColorPickerPanelManager.shared.showColorPickerPanel()
|
||||
dismiss()
|
||||
}) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "rectangle.grid.2x2")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Text("Show Color History")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.gray.opacity(0.2))
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
// Recent colors preview (if any)
|
||||
if !colorPickerManager.colorHistory.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Recent Colors")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 4), count: 6), spacing: 4) {
|
||||
ForEach(Array(colorPickerManager.colorHistory.prefix(12))) { color in
|
||||
Button(action: {
|
||||
colorPickerManager.copyToClipboard(color.hexString)
|
||||
|
||||
if Defaults[.enableHaptics] {
|
||||
NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default)
|
||||
}
|
||||
|
||||
dismiss()
|
||||
}) {
|
||||
Circle()
|
||||
.fill(color.color)
|
||||
.frame(width: 24, height: 24)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.8), lineWidth: 1)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.black.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(width: 240)
|
||||
.background(VisualEffectView(material: .hudWindow, blendingMode: .behindWindow))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ColorPickerPopover()
|
||||
.onAppear {
|
||||
ColorPickerManager.shared.colorHistory = PickedColor.sampleColors
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,24 @@ struct DynamicIslandHeader: View {
|
|||
}
|
||||
}
|
||||
|
||||
// ColorPicker button
|
||||
if Defaults[.enableColorPickerFeature] {
|
||||
Button(action: {
|
||||
ColorPickerPanelManager.shared.toggleColorPickerPanel()
|
||||
}) {
|
||||
Capsule()
|
||||
.fill(.black)
|
||||
.frame(width: 30, height: 30)
|
||||
.overlay {
|
||||
Image(systemName: "eyedropper")
|
||||
.foregroundColor(.white)
|
||||
.padding()
|
||||
.imageScale(.medium)
|
||||
}
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
|
||||
if Defaults[.settingsIconInNotch] {
|
||||
Button(action: {
|
||||
SettingsWindowController.shared.showWindow()
|
||||
|
|
|
|||
351
DynamicIsland/components/Notch/NotchColorPickerView.swift
Normal file
351
DynamicIsland/components/Notch/NotchColorPickerView.swift
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
//
|
||||
// NotchColorPickerView.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by GitHub Copilot on 14/08/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Defaults
|
||||
|
||||
struct NotchColorPickerView: View {
|
||||
@ObservedObject var colorPickerManager = ColorPickerManager.shared
|
||||
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
||||
@State private var hoveredColorId: UUID?
|
||||
@State private var showColorInfo: Bool = false
|
||||
@State private var selectedColor: PickedColor?
|
||||
|
||||
var body: some View {
|
||||
if !enableColorPickerFeature {
|
||||
disabledStateView
|
||||
} else {
|
||||
colorPickerContent
|
||||
}
|
||||
}
|
||||
|
||||
private var disabledStateView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "eyedropper.halffull")
|
||||
.font(.system(size: 32))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("ColorPicker Disabled")
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Enable ColorPicker in Settings → ColorPicker")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var colorPickerContent: some View {
|
||||
VStack(spacing: 16) {
|
||||
headerSection
|
||||
|
||||
if colorPickerManager.colorHistory.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
colorHistoryGrid
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.overlay(
|
||||
colorInfoOverlay,
|
||||
alignment: .topTrailing
|
||||
)
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "eyedropper.halffull")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Recent Colors")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
|
||||
Text("\(colorPickerManager.colorHistory.count) colors")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Pick Color Button
|
||||
Button(action: {
|
||||
colorPickerManager.startColorPicking()
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
Text("Pick Color")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
}
|
||||
.foregroundColor(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.blue)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.onHover { isHovering in
|
||||
if isHovering {
|
||||
NSCursor.pointingHand.push()
|
||||
} else {
|
||||
NSCursor.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "eyedropper")
|
||||
.font(.system(size: 28))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text("No Colors Yet")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text("Click 'Pick Color' or use Cmd+Shift+P to start picking colors")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 40)
|
||||
}
|
||||
|
||||
private var colorHistoryGrid: some View {
|
||||
LazyVGrid(columns: gridColumns, spacing: 12) {
|
||||
ForEach(colorPickerManager.colorHistory.prefix(15)) { color in
|
||||
ColorCircleView(
|
||||
color: color,
|
||||
isHovered: hoveredColorId == color.id
|
||||
)
|
||||
.onHover { isHovering in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
if isHovering {
|
||||
hoveredColorId = color.id
|
||||
selectedColor = color
|
||||
showColorInfo = true
|
||||
} else {
|
||||
hoveredColorId = nil
|
||||
if selectedColor?.id == color.id {
|
||||
showColorInfo = false
|
||||
selectedColor = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
// Copy hex color to clipboard
|
||||
let pasteboard = NSPasteboard.general
|
||||
pasteboard.clearContents()
|
||||
pasteboard.setString(color.hexString, forType: .string)
|
||||
|
||||
// Provide haptic feedback
|
||||
if Defaults[.enableHaptics] {
|
||||
NSHapticFeedbackManager.defaultPerformer.perform(.generic, performanceTime: .default)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
private var gridColumns: [GridItem] {
|
||||
let availableWidth: CGFloat = 400 // Approximate notch content width
|
||||
let circleSize: CGFloat = 32
|
||||
let spacing: CGFloat = 12
|
||||
let maxColumns = Int((availableWidth + spacing) / (circleSize + spacing))
|
||||
let columns = min(maxColumns, 8) // Maximum 8 columns
|
||||
|
||||
return Array(repeating: GridItem(.fixed(circleSize), spacing: spacing), count: columns)
|
||||
}
|
||||
|
||||
private var colorInfoOverlay: some View {
|
||||
Group {
|
||||
if showColorInfo, let color = selectedColor {
|
||||
ColorInfoPopup(color: color)
|
||||
.transition(.asymmetric(
|
||||
insertion: .scale.combined(with: .opacity),
|
||||
removal: .opacity
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorCircleView: View {
|
||||
let color: PickedColor
|
||||
let isHovered: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Color circle
|
||||
Circle()
|
||||
.fill(color.color)
|
||||
.frame(width: 32, height: 32)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.8), lineWidth: isHovered ? 2 : 1)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.black.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.3), radius: isHovered ? 4 : 2, x: 0, y: isHovered ? 2 : 1)
|
||||
.scaleEffect(isHovered ? 1.1 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.2), value: isHovered)
|
||||
}
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorInfoPopup: View {
|
||||
let color: PickedColor
|
||||
@Default(.showColorFormats) var showColorFormats
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Header with color preview
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(color.color)
|
||||
.frame(width: 24, height: 24)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.white.opacity(0.8), lineWidth: 1)
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Picked Color")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(.primary)
|
||||
|
||||
Text(timeAgoString(from: color.timestamp))
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Divider()
|
||||
.background(Color.gray.opacity(0.3))
|
||||
|
||||
// Color formats
|
||||
if showColorFormats {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ForEach(color.allFormats.prefix(4)) { format in
|
||||
ColorFormatRow(format: format, color: color)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ColorFormatRow(
|
||||
format: ColorFormat(name: "HEX", value: color.hexString, copyValue: color.hexString),
|
||||
color: color
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(width: 220)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(Color.black.opacity(0.9))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.gray.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.4), radius: 15, x: 0, y: 8)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ColorFormatRow: View {
|
||||
let format: ColorFormat
|
||||
let color: PickedColor
|
||||
@State private var isHovered: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(format.name)
|
||||
.font(.system(size: 10, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 40, alignment: .leading)
|
||||
|
||||
Text(format.value)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
Spacer()
|
||||
|
||||
if isHovered {
|
||||
Button(action: {
|
||||
ColorPickerManager.shared.copyColorToClipboard(color, format: format)
|
||||
}) {
|
||||
Image(systemName: "doc.on.doc")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(.blue)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(isHovered ? Color.white.opacity(0.1) : Color.clear)
|
||||
)
|
||||
.onHover { hovering in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isHovered = hovering
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
ColorPickerManager.shared.copyColorToClipboard(color, format: format)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NotchColorPickerView()
|
||||
.frame(width: 500, height: 200)
|
||||
.background(Color.black)
|
||||
.onAppear {
|
||||
// Add some sample colors for preview
|
||||
let manager = ColorPickerManager.shared
|
||||
manager.colorHistory = PickedColor.sampleColors
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ struct TabSelectionView: View {
|
|||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@Default(.enableTimerFeature) var enableTimerFeature
|
||||
@Default(.enableStatsFeature) var enableStatsFeature
|
||||
@Default(.enableColorPickerFeature) var enableColorPickerFeature
|
||||
@Namespace var animation
|
||||
|
||||
var availableTabs: [TabModel] {
|
||||
|
|
@ -35,6 +36,10 @@ struct TabSelectionView: View {
|
|||
tabs.append(TabModel(label: "Stats", icon: "chart.line.uptrend.xyaxis", view: .stats))
|
||||
}
|
||||
|
||||
if enableColorPickerFeature {
|
||||
tabs.append(TabModel(label: "ColorPicker", icon: "eyedropper", view: .colorPicker))
|
||||
}
|
||||
|
||||
return tabs
|
||||
}
|
||||
var body: some View {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue