diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 5db8257..2818053 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -142,6 +142,12 @@ final class ClipboardStore { scheduleSave() } + func setPinboardColor(_ id: UUID, to colorHex: String) { + guard let i = pinboards.firstIndex(where: { $0.id == id }) else { return } + pinboards[i].colorHex = colorHex + scheduleSave() + } + func deletePinboard(_ id: UUID) { guard let i = pinboards.firstIndex(where: { $0.id == id }) else { return } if case .pinboard(let cur) = source, cur == id { source = .history } diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index e4c1b73..2e55e11 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -7,7 +7,12 @@ struct ClipCardView: View { @State private var hovering = false private var store: ClipboardStore { ClipboardStore.shared } - private var headerColor: Color { SourceColor.color(for: item.sourceBundleID) } + private var activePinboardColor: Color? { + guard case .pinboard(let id) = store.source else { return nil } + return store.pinboards.first(where: { $0.id == id })?.color + } + private var headerColor: Color { activePinboardColor ?? SourceColor.color(for: item.sourceBundleID) } + private var selectionColor: Color { activePinboardColor ?? Theme.selection } var body: some View { VStack(spacing: 0) { @@ -18,7 +23,7 @@ struct ClipCardView: View { .clipShape(RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: Theme.cardCorner, style: .continuous) - .strokeBorder(selected ? Theme.selection : Theme.cardBorder, + .strokeBorder(selected ? selectionColor : Theme.cardBorder, lineWidth: selected ? 2.5 : 1) ) .shadow(color: .black.opacity(selected ? 0.35 : 0.18), diff --git a/Sources/Pesty/UI/PinboardTabs.swift b/Sources/Pesty/UI/PinboardTabs.swift index 01aa291..8aeaaa0 100644 --- a/Sources/Pesty/UI/PinboardTabs.swift +++ b/Sources/Pesty/UI/PinboardTabs.swift @@ -1,7 +1,14 @@ +import AppKit import SwiftUI struct PinboardTabs: View { @Bindable private var store = ClipboardStore.shared + @State private var editingBoardID: UUID? + @State private var draftName = "" + @FocusState private var focusedBoardID: UUID? + + private let activePillFill = Color.white.opacity(0.25) + private let inactiveTabText = Color.white.opacity(0.84) var body: some View { ScrollView(.horizontal, showsIndicators: false) { @@ -14,13 +21,26 @@ struct PinboardTabs: View { } ForEach(store.pinboards) { board in - pill(title: board.name, - dot: board.color, - selected: store.source == .pinboard(board.id)) { - store.source = .pinboard(board.id); store.selectFirst() - } + boardTab(board) .contextMenu { - Button("Rename…") { rename(board) } + Button { rename(board) } label: { + Label("Rename…", systemImage: "pencil") + } + Menu { + ForEach(PinboardColorOption.all) { color in + Button { store.setPinboardColor(board.id, to: color.hex) } label: { + Label { + Text(board.colorHex.caseInsensitiveCompare(color.hex) == .orderedSame + ? "✓ \(color.name)" + : color.name) + } icon: { + Image(nsImage: color.menuSwatch) + } + } + } + } label: { + Label("Color", systemImage: "paintpalette") + } Button("Delete Pinboard", role: .destructive) { store.deletePinboard(board.id) } @@ -38,6 +58,38 @@ struct PinboardTabs: View { .help("New Pinboard") } } + .onChange(of: focusedBoardID) { oldValue, newValue in + if let oldValue, oldValue == editingBoardID, newValue != oldValue { + finishEditing() + } + } + } + + @ViewBuilder + private func boardTab(_ board: Pinboard) -> some View { + if editingBoardID == board.id { + HStack(spacing: 6) { + Circle().fill(board.color).frame(width: 7, height: 7) + TextField("Pinboard name", text: $draftName) + .textFieldStyle(.plain) + .font(.system(size: 12.5, weight: .medium)) + .foregroundStyle(Theme.textPrimary) + .frame(minWidth: 92, idealWidth: 120) + .focused($focusedBoardID, equals: board.id) + .onSubmit(finishEditing) + } + .padding(.horizontal, 12) + .frame(height: 29) + .background(activePillFill, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) + .fixedSize() + } else { + pill(title: board.name, + dot: board.color, + selected: store.source == .pinboard(board.id)) { + store.source = .pinboard(board.id); store.selectFirst() + } + } } private func pill(title: String, dot: Color?, icon: String? = nil, @@ -55,10 +107,13 @@ struct PinboardTabs: View { .font(.system(size: 12.5, weight: .medium)) .lineLimit(1) } - .foregroundStyle(selected ? Theme.textPrimary : Theme.textSecondary) + .foregroundStyle(selected ? Theme.textPrimary : inactiveTabText) .padding(.horizontal, 12) .frame(height: 29) - .background(selected ? Theme.pillSelected : Theme.pillBG, in: Capsule()) + .background(selected ? activePillFill : .clear, in: Capsule()) + .overlay( + Capsule().strokeBorder(Color.white.opacity(selected ? 0.12 : 0), lineWidth: 1) + ) } .buttonStyle(.plain) .fixedSize() @@ -68,15 +123,68 @@ struct PinboardTabs: View { private func addPinboard() { let board = store.addPinboard(name: "New Pinboard") store.source = .pinboard(board.id) + beginEditing(board) } private func rename(_ board: Pinboard) { - if let name = TextPrompt.run(title: "Rename Pinboard", - message: "Enter a new name", - defaultValue: board.name) { - store.renamePinboard(board.id, to: name) + beginEditing(board) + } + + private func beginEditing(_ board: Pinboard) { + editingBoardID = board.id + draftName = board.name + DispatchQueue.main.async { + focusedBoardID = board.id + NSApp.sendAction(#selector(NSText.selectAll(_:)), to: nil, from: nil) } } + + private func finishEditing() { + guard let id = editingBoardID else { return } + let name = draftName.trimmingCharacters(in: .whitespacesAndNewlines) + if !name.isEmpty { store.renamePinboard(id, to: name) } + editingBoardID = nil + focusedBoardID = nil + } +} + +private struct PinboardColorOption: Identifiable { + let name: String + let hex: String + + var id: String { hex } + + /// AppKit's native menus retain `NSImage` icons but discard SwiftUI shapes, + /// so use a small non-template image for a reliably colored menu swatch. + var menuSwatch: NSImage { + let size = NSSize(width: 13, height: 13) + let image = NSImage(size: size) + image.lockFocus() + + let inset: CGFloat = 1 + let circle = NSRect(x: inset, y: inset, + width: size.width - inset * 2, + height: size.height - inset * 2) + (NSColor(hex: hex) ?? .controlAccentColor).setFill() + NSBezierPath(ovalIn: circle).fill() + NSColor.black.withAlphaComponent(0.18).setStroke() + NSBezierPath(ovalIn: circle).stroke() + + image.unlockFocus() + image.isTemplate = false + return image + } + + static let all = [ + Self(name: "Red", hex: "#FF3B5C"), + Self(name: "Orange", hex: "#FF8A2B"), + Self(name: "Yellow", hex: "#F5B700"), + Self(name: "Green", hex: "#34C759"), + Self(name: "Blue", hex: "#0A84FF"), + Self(name: "Purple", hex: "#BF3BE0"), + Self(name: "Pink", hex: "#FF2D55"), + Self(name: "Gray", hex: "#98989F") + ] } @MainActor