Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
9 changes: 7 additions & 2 deletions Sources/Pesty/UI/ClipCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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),
Expand Down
132 changes: 120 additions & 12 deletions Sources/Pesty/UI/PinboardTabs.swift
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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)
}
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -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
Expand Down