From 987c28ac85d94b20004a7a38c4ae6a9779af39d4 Mon Sep 17 00:00:00 2001 From: Ashikur Rahman Date: Fri, 24 Jul 2026 18:19:24 -0400 Subject: [PATCH] feat: Add Pinned Topics and 'Keep Open' functionality - Segregates standard history items (Clipboard) and pinned items (Pinned Comments) into separate tabs using a Segmented Picker. - Introduces persistent 'Topics' for grouping pinned comments (stored in AppStorage). - Includes an NSAlert-based topic creation UI for native dialog interaction. - Adds drag-and-drop and context-menu support for assigning items to topics. - Enhances topics with expandable/collapsible accordion functionality. - Adds a 'Keep Open' pin button to the main HeaderView to override automatic closing on resignKey. --- Maccy/FloatingPanel.swift | 5 +- Maccy/Models/HistoryItem.swift | 1 + Maccy/Observables/HistoryItemDecorator.swift | 4 + Maccy/Views/ContentView.swift | 178 ++++++++++++++++++- Maccy/Views/HeaderView.swift | 10 ++ Maccy/Views/HistoryItemView.swift | 19 ++ Maccy/Views/HistoryListView.swift | 10 +- 7 files changed, 216 insertions(+), 11 deletions(-) diff --git a/Maccy/FloatingPanel.swift b/Maccy/FloatingPanel.swift index 60f3becff..3c076cb04 100644 --- a/Maccy/FloatingPanel.swift +++ b/Maccy/FloatingPanel.swift @@ -194,8 +194,9 @@ class FloatingPanel: NSPanel, NSWindowDelegate { // Close automatically when out of focus, e.g. outside click. override func resignKey() { super.resignKey() - // Don't hide if confirmation is shown. - if NSApp.alertWindow == nil { + // Don't hide if confirmation is shown or keepOpen is enabled + let keepOpen = UserDefaults.standard.bool(forKey: "keepOpen") + if NSApp.alertWindow == nil && !keepOpen { close() } } diff --git a/Maccy/Models/HistoryItem.swift b/Maccy/Models/HistoryItem.swift index bba484ac3..19e6db88e 100644 --- a/Maccy/Models/HistoryItem.swift +++ b/Maccy/Models/HistoryItem.swift @@ -69,6 +69,7 @@ class HistoryItem { var lastCopiedAt: Date = Date.now var numberOfCopies: Int = 1 var pin: String? + var topic: String? var title = "" @Relationship(deleteRule: .cascade, inverse: \HistoryItemContent.item) diff --git a/Maccy/Observables/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index f402e1259..899f041f6 100644 --- a/Maccy/Observables/HistoryItemDecorator.swift +++ b/Maccy/Observables/HistoryItemDecorator.swift @@ -55,6 +55,10 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { var isPinned: Bool { item.pin != nil } var isUnpinned: Bool { item.pin == nil } + var topic: String? { + get { item.topic } + set { item.topic = newValue } + } func hash(into hasher: inout Hasher) { // We need to hash title and attributedTitle, so SwiftUI knows it needs to update the view if they chage diff --git a/Maccy/Views/ContentView.swift b/Maccy/Views/ContentView.swift index 72fe0d94f..316952ab1 100644 --- a/Maccy/Views/ContentView.swift +++ b/Maccy/Views/ContentView.swift @@ -1,5 +1,6 @@ import SwiftData import SwiftUI +import UniformTypeIdentifiers struct ContentView: View { @State private var appState = AppState.shared @@ -7,6 +8,7 @@ struct ContentView: View { @State private var scenePhase: ScenePhase = .background @FocusState private var searchFocused: Bool + @State private var selectedTab: Int = 0 var body: some View { ZStack { @@ -24,14 +26,30 @@ struct ContentView: View { searchFocused: $searchFocused ) + Picker("", selection: $selectedTab) { + Text("Clipboard").tag(0) + Text("Pinned Comments").tag(1) + } + .pickerStyle(.segmented) + .padding(.horizontal, Popup.horizontalPadding) + .padding(.top, 4) + .onChange(of: selectedTab) { + appState.popup.needsResize = true + } + VStack(alignment: .leading, spacing: 0) { - HistoryListView( - searchQuery: $appState.history.searchQuery, - searchFocused: $searchFocused - ) + if selectedTab == 0 { + HistoryListView( + searchQuery: $appState.history.searchQuery, + searchFocused: $searchFocused + ) + } else { + PinnedCommentsView() + } FooterView(footer: appState.footer) } + .animation(.default, value: selectedTab) .animation(.default.speed(3), value: appState.history.items) .animation( .default.speed(3), @@ -83,3 +101,155 @@ struct ContentView: View { .environment(\.locale, .init(identifier: "en")) .modelContainer(Storage.shared.container) } + + +struct PinnedCommentsView: View { + @Environment(AppState.self) private var appState + @AppStorage("customTopics") private var customTopicsData: Data = Data() + @AppStorage("expandedTopics") private var expandedTopicsData: Data = Data() + + private var customTopics: [String] { + get { + if let decoded = try? JSONDecoder().decode([String].self, from: customTopicsData) { return decoded } + return [] + } + nonmutating set { + if let encoded = try? JSONEncoder().encode(newValue) { customTopicsData = encoded } + } + } + + private var expandedTopics: Set { + get { + if let decoded = try? JSONDecoder().decode(Set.self, from: expandedTopicsData) { return decoded } + return Set(["Uncategorized"]) + } + nonmutating set { + if let encoded = try? JSONEncoder().encode(newValue) { expandedTopicsData = encoded } + } + } + + private func promptForNewTopic() { + let alert = NSAlert() + alert.messageText = "New Topic" + alert.informativeText = "Enter a name for the new topic." + alert.addButton(withTitle: "Add") + alert.addButton(withTitle: "Cancel") + + let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) + alert.accessoryView = textField + alert.window.initialFirstResponder = textField + + NSApp.activate(ignoringOtherApps: true) + + let response = alert.runModal() + if response == .alertFirstButtonReturn { + let trimmed = textField.stringValue.trimmingCharacters(in: .whitespaces) + if !trimmed.isEmpty && !customTopics.contains(trimmed) { + var updated = customTopics + updated.append(trimmed) + customTopics = updated + } + } + } + + var body: some View { + let pinnedItems = appState.history.pinnedItems + let itemTopics = Set(pinnedItems.compactMap { $0.topic }) + let topics = itemTopics.union(customTopics).union(["Uncategorized"]).sorted() + let grouped = Dictionary(grouping: pinnedItems, by: { $0.topic ?? "Uncategorized" }) + + VStack(alignment: .leading, spacing: 0) { + HStack { + Spacer() + Button(action: promptForNewTopic) { + Text("Add Topic...") + .font(.subheadline) + } + .buttonStyle(.plain) + .padding(.horizontal, 15) + .padding(.vertical, 8) + } + + ScrollView { + VStack(alignment: .leading, spacing: 10) { + ForEach(topics, id: \.self) { topic in + let isExpanded = expandedTopics.contains(topic) + VStack(alignment: .leading, spacing: 0) { + HStack { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .frame(width: 16) + .foregroundColor(.secondary) + Text(topic) + .font(.headline) + Spacer() + if topic != "Uncategorized" { + Button(action: { + customTopics.removeAll { $0 == topic } + for item in grouped[topic] ?? [] { + item.topic = nil + } + }) { + Image(systemName: "trash") + .foregroundColor(.red) + } + .buttonStyle(.plain) + .help("Delete Topic") + } + } + .padding(.top, 10) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + .onTapGesture { + var expanded = expandedTopics + if expanded.contains(topic) { + expanded.remove(topic) + } else { + expanded.insert(topic) + } + expandedTopics = expanded + appState.popup.needsResize = true + } + + if isExpanded { + VStack(spacing: 0) { + ForEach(grouped[topic] ?? []) { item in + HistoryItemView(item: item, previous: nil, next: nil, index: -1) + .draggable(item.id.uuidString) + } + } + .padding(.top, 5) + } + } + .dropDestination(for: String.self) { items, _ in + for idString in items { + if let id = UUID(uuidString: idString) { + DispatchQueue.main.async { + if let movedItem = appState.history.pinnedItems.first(where: { $0.id == id }) { + movedItem.topic = (topic == "Uncategorized") ? nil : topic + } + } + } + } + return true + } + } + } + .padding(.vertical, 5) + .background { + GeometryReader { geo in + Color.clear + .task(id: appState.popup.needsResize) { + try? await Task.sleep(for: .milliseconds(10)) + guard !Task.isCancelled else { return } + + if appState.popup.needsResize { + appState.popup.resize(height: geo.size.height) + } + } + } + } + } + } + } +} diff --git a/Maccy/Views/HeaderView.swift b/Maccy/Views/HeaderView.swift index 24d8bba11..a656821aa 100644 --- a/Maccy/Views/HeaderView.swift +++ b/Maccy/Views/HeaderView.swift @@ -3,6 +3,7 @@ import SwiftUI struct HeaderView: View { @State private var appState = AppState.shared + @AppStorage("keepOpen") private var keepOpen: Bool = false let controller: SlideoutController @FocusState.Binding var searchFocused: Bool @@ -34,6 +35,15 @@ struct HeaderView: View { tableName: "PreviewItemView", replacementKey: "previewKey" ) + .padding(.trailing, 5) + + ToolbarButton { + keepOpen.toggle() + } label: { + Image(systemName: keepOpen ? "pin.fill" : "pin") + .foregroundColor(keepOpen ? .accentColor : .primary) + } + .help("Keep window open") .padding(.trailing, Popup.horizontalPadding) } .opacity(appState.searchVisible ? 1 : 0) diff --git a/Maccy/Views/HistoryItemView.swift b/Maccy/Views/HistoryItemView.swift index 9e25b3a5f..a012e68ca 100644 --- a/Maccy/Views/HistoryItemView.swift +++ b/Maccy/Views/HistoryItemView.swift @@ -64,5 +64,24 @@ struct HistoryItemView: View { } } } + .contextMenu { + Menu("Pin to Topic...") { + ForEach(allTopics, id: \.self) { targetTopic in + Button(targetTopic) { + if item.isUnpinned { + item.togglePin() + } + item.topic = (targetTopic == "Uncategorized") ? nil : targetTopic + } + } + } + } + } + + @AppStorage("customTopics") private var customTopicsData: Data = Data() + private var allTopics: [String] { + let itemTopics = Set(appState.history.pinnedItems.compactMap { $0.topic }) + let custom = (try? JSONDecoder().decode([String].self, from: customTopicsData)) ?? [] + return itemTopics.union(custom).union(["Uncategorized"]).sorted() } } diff --git a/Maccy/Views/HistoryListView.swift b/Maccy/Views/HistoryListView.swift index 1bf9f7212..7441279fb 100644 --- a/Maccy/Views/HistoryListView.swift +++ b/Maccy/Views/HistoryListView.swift @@ -66,12 +66,12 @@ struct HistoryListView: View { } var body: some View { - let topPinsVisible = pinTo == .top && pinsVisible - let bottomPinsVisible = pinTo == .bottom && pinsVisible - let topSeparatorVisible = topPinsVisible || pasteStackVisible - let bottomSeparatorVisible = bottomPinsVisible + let topPinsVisible = false + let bottomPinsVisible = false + let topSeparatorVisible = pasteStackVisible + let bottomSeparatorVisible = false let scrollTopPadding = topSeparatorVisible ? Popup.verticalSeparatorPadding : topPadding - let scrollBottomPadding = bottomSeparatorVisible ? Popup.verticalSeparatorPadding : bottomPadding + let scrollBottomPadding = bottomPadding VStack(spacing: 0) { if let stack = appState.history.pasteStack,