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
5 changes: 3 additions & 2 deletions Maccy/FloatingPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,9 @@ class FloatingPanel<Content: View>: 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()
}
}
Expand Down
1 change: 1 addition & 0 deletions Maccy/Models/HistoryItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions Maccy/Observables/HistoryItemDecorator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
178 changes: 174 additions & 4 deletions Maccy/Views/ContentView.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import SwiftData
import SwiftUI
import UniformTypeIdentifiers

struct ContentView: View {
@State private var appState = AppState.shared
@State private var modifierFlags = ModifierFlags()
@State private var scenePhase: ScenePhase = .background

@FocusState private var searchFocused: Bool
@State private var selectedTab: Int = 0

var body: some View {
ZStack {
Expand All @@ -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),
Expand Down Expand Up @@ -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<String> {
get {
if let decoded = try? JSONDecoder().decode(Set<String>.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)
}
}
}
}
}
}
}
}
10 changes: 10 additions & 0 deletions Maccy/Views/HeaderView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions Maccy/Views/HistoryItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
10 changes: 5 additions & 5 deletions Maccy/Views/HistoryListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down