Skip to content

Commit 1d2e874

Browse files
committed
v1.1.0: multi-select, glass preview, footer, Clear confirmation
UI: - Multi-select via row context-menu Select; whole-row tap toggles selection in select mode (no leading checkboxes) - Clear button now opens a system confirmation dialog; Return triggers the destructive action, Esc cancels - Attribution footer at the bottom of the panel - Per-row copy glyph removed; pin glyph upsized 11pt -> 14pt - Full-text preview popover uses NSVisualEffectView (.popover material) for a Liquid-Glass-like translucent surface Layout: - Header label removed; search bar leads with 20pt top padding - Scroll content height shrunk to 408pt to make room for footer Project: - MARKETING_VERSION 1.1.0, CURRENT_PROJECT_VERSION 2 - CHANGELOG entry for 1.1.0 - README: Homebrew install instructions
1 parent 8b29b58 commit 1d2e874

4 files changed

Lines changed: 199 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33
All notable changes to this project are documented here. Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [Semantic Versioning](https://semver.org/).
44

5+
## [1.1.0] — 2026-05-29
6+
7+
### Added
8+
9+
- **Multi-select mode** — right-click any row → *Select* enters multi-select. Click anywhere on a row to toggle its selection (no leading checkboxes); a stronger accent tint plus a trailing checkmark indicates selected rows. The Clear button morphs into Cancel + **Delete N**. Esc exits the mode.
10+
- **Confirmation dialog on Clear** — system-style sheet asks before destroying history; Return triggers the destructive action, Esc cancels. ⌥-click variant uses a stronger warning ("Clear all items, including pinned?").
11+
- **Attribution footer** — "Clip-Board by Siddharth Sangwan" with a small clipboard glyph, centered at the bottom of the panel.
12+
- **Homebrew tap** — install via `brew install --cask light-house-group/tap/clip-board`.
13+
14+
### Changed
15+
16+
- **Header removed.** The redundant "Clipboard" label and icon are gone; top padding adjusted (20 pt) to keep the search bar off the rounded edge.
17+
- **Per-row trailing actions simplified.** Removed the per-row copy glyph (redundant with row tap which auto-pastes and with right-click → Copy). Pin glyph upsized from 11 pt to 14 pt for stronger affordance.
18+
- **Glass preview popover.** The full-text hover popover now uses `NSVisualEffectView` with the system popover material for a Liquid-Glass-style translucent surface that adapts to the macOS Tahoe aesthetic.
19+
- **Sharper Escape priority** in the panel: preview → exit-select → clear-search → close-panel.
20+
21+
[1.1.0]: https://github.com/Light-House-Group/Clip-Board/releases/tag/v1.1.0
22+
523
## [1.0.0] — 2026-05-29
624

725
First public release.

Clip Board.xcodeproj/project.pbxproj

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@
254254
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
255255
CODE_SIGN_STYLE = Automatic;
256256
COMBINE_HIDPI_IMAGES = YES;
257-
CURRENT_PROJECT_VERSION = 1;
257+
CURRENT_PROJECT_VERSION = 2;
258258
DEVELOPMENT_TEAM = 474XD43PW6;
259259
ENABLE_APP_SANDBOX = YES;
260260
ENABLE_HARDENED_RUNTIME = YES;
@@ -271,7 +271,7 @@
271271
"@executable_path/../Frameworks",
272272
);
273273
MACOSX_DEPLOYMENT_TARGET = 14;
274-
MARKETING_VERSION = 1.0;
274+
MARKETING_VERSION = 1.1.0;
275275
PRODUCT_BUNDLE_IDENTIFIER = Siddharth.Sangwa.ClipBoard;
276276
PRODUCT_NAME = "$(TARGET_NAME)";
277277
REGISTER_APP_GROUPS = YES;
@@ -293,7 +293,7 @@
293293
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "-";
294294
CODE_SIGN_STYLE = Automatic;
295295
COMBINE_HIDPI_IMAGES = YES;
296-
CURRENT_PROJECT_VERSION = 1;
296+
CURRENT_PROJECT_VERSION = 2;
297297
DEVELOPMENT_TEAM = 474XD43PW6;
298298
ENABLE_APP_SANDBOX = YES;
299299
ENABLE_HARDENED_RUNTIME = YES;
@@ -310,7 +310,7 @@
310310
"@executable_path/../Frameworks",
311311
);
312312
MACOSX_DEPLOYMENT_TARGET = 14;
313-
MARKETING_VERSION = 1.0;
313+
MARKETING_VERSION = 1.1.0;
314314
PRODUCT_BUNDLE_IDENTIFIER = Siddharth.Sangwa.ClipBoard;
315315
PRODUCT_NAME = "$(TARGET_NAME)";
316316
REGISTER_APP_GROUPS = YES;

Clip Board/UIViews.swift

Lines changed: 157 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ private enum HistoryUI {
9494
static let panelWidth: CGFloat = 340
9595
static let panelHeight: CGFloat = 500
9696
static let contentWidth: CGFloat = 328
97-
static let contentHeight: CGFloat = 440
97+
static let contentHeight: CGFloat = 408
9898

9999
static let outerPadding: CGFloat = 12
100100
static let innerHorizontal: CGFloat = 10
@@ -122,15 +122,34 @@ struct SharedHistoryRootView: View {
122122
// No drag grabber — the entire panel is draggable via
123123
// `isMovableByWindowBackground`, so the capsule was decorative-only
124124
// and just pushed real content down.
125-
ContentView()
126-
.environmentObject(itemsVM)
127-
.padding(.horizontal, HistoryUI.innerHorizontal)
128-
.padding(.bottom, HistoryUI.innerVertical)
125+
VStack(spacing: 0) {
126+
ContentView()
127+
.environmentObject(itemsVM)
128+
.padding(.horizontal, HistoryUI.innerHorizontal)
129+
130+
attributionFooter
131+
.padding(.horizontal, HistoryUI.innerHorizontal)
132+
.padding(.top, 6)
133+
.padding(.bottom, HistoryUI.innerVertical + 2)
134+
}
129135
}
130136
.padding(HistoryUI.outerPadding)
131137
.frame(width: HistoryUI.panelWidth, height: HistoryUI.panelHeight)
132138
.background(Color.clear)
133139
}
140+
141+
/// Centered attribution footer. Quiet styling so it doesn't compete with content.
142+
private var attributionFooter: some View {
143+
HStack(spacing: 6) {
144+
Image(systemName: "doc.on.clipboard")
145+
.font(.caption2)
146+
.foregroundStyle(.secondary)
147+
Text("Clip-Board by Siddharth Sangwan")
148+
.font(.caption2)
149+
.foregroundStyle(.secondary)
150+
}
151+
.frame(maxWidth: .infinity)
152+
}
134153
}
135154

136155
// MARK: - Focus ringless TextField (macOS)
@@ -204,6 +223,39 @@ struct ContentView: View {
204223
@State private var keyboardNavigated: Bool = false
205224
private let copyHighlightDuration: TimeInterval = 0.6
206225

226+
// Multi-select mode — entered via row context-menu "Select".
227+
// Click anywhere on a row toggles its selection; no leading checkboxes.
228+
@State private var isMultiSelectMode: Bool = false
229+
@State private var multiSelectedIDs: Set<UUID> = []
230+
231+
// System confirmation before Clear destroys a batch of items.
232+
private enum ClearScope { case unpinnedOnly, all }
233+
@State private var pendingClear: ClearScope? = nil
234+
235+
private func enterSelectMode(initialID: UUID? = nil) {
236+
isMultiSelectMode = true
237+
multiSelectedIDs = initialID.map { [$0] } ?? []
238+
previewItemID = nil
239+
selectedID = nil
240+
}
241+
private func exitSelectMode() {
242+
isMultiSelectMode = false
243+
multiSelectedIDs.removeAll()
244+
}
245+
private func toggleMultiSelection(_ id: UUID) {
246+
if multiSelectedIDs.contains(id) { multiSelectedIDs.remove(id) }
247+
else { multiSelectedIDs.insert(id) }
248+
}
249+
private func deleteMultiSelected() {
250+
for id in multiSelectedIDs {
251+
itemsVM.deleteItem(id)
252+
if selectedID == id { selectedID = nil }
253+
if previewItemID == id { previewItemID = nil }
254+
copiedTimestamps.removeValue(forKey: id)
255+
}
256+
exitSelectMode()
257+
}
258+
207259
/// Single-pass snapshot of items split into pinned/unpinned display buckets.
208260
/// Replaces the previous seven-stage cascade; one O(n) walk + one prefix.
209261
private struct Snapshot {
@@ -266,22 +318,38 @@ struct ContentView: View {
266318
item: item,
267319
isSelected: selectedID == item.id,
268320
isCopied: isRecentlyCopied(item.id),
321+
isMultiSelectMode: isMultiSelectMode,
322+
isMultiSelected: multiSelectedIDs.contains(item.id),
269323
previewItemID: $previewItemID
270324
)
271325
.id(item.id)
272326
.contentShape(Rectangle())
273-
.onTapGesture { perform(action: .copy, id: item.id) }
327+
.onTapGesture {
328+
if isMultiSelectMode {
329+
toggleMultiSelection(item.id)
330+
} else {
331+
perform(action: .copy, id: item.id)
332+
}
333+
}
274334
.onHover { hovering in
275335
if hovering { hoverID = item.id; selectedID = item.id } else if hoverID == item.id { hoverID = nil; if selectedID == item.id { selectedID = nil } }
276336
}
277337
.contextMenu {
278338
Button(item.pinned ? "Unpin" : "Pin") { withAnimation(.easeInOut(duration: 0.15)) { itemsVM.togglePin(item.id) } }
279339
Button("Copy") { perform(action: .copy, id: item.id) }
340+
Button(isMultiSelectMode ? "Add to Selection" : "Select") {
341+
if isMultiSelectMode {
342+
multiSelectedIDs.insert(item.id)
343+
} else {
344+
enterSelectMode(initialID: item.id)
345+
}
346+
}
280347
Divider()
281348
Button("Delete", role: .destructive) {
282349
itemsVM.deleteItem(item.id)
283350
if selectedID == item.id { selectedID = nil }
284351
if previewItemID == item.id { previewItemID = nil }
352+
multiSelectedIDs.remove(item.id)
285353
copiedTimestamps.removeValue(forKey: item.id)
286354
}
287355
}
@@ -297,17 +365,10 @@ struct ContentView: View {
297365

298366
var body: some View {
299367
VStack(spacing: 0) {
300-
HStack(spacing: 8) {
301-
Image(systemName: "doc.on.clipboard").imageScale(.medium).foregroundStyle(.secondary)
302-
Text("Clipboard").font(.subheadline.weight(.semibold)).foregroundStyle(.secondary)
303-
Spacer()
304-
}
305-
.padding(.horizontal, HistoryUI.innerHorizontal)
306-
.padding(.top, 18)
307-
.padding(.bottom, 6)
308-
368+
// "Clipboard" title removed per design — search bar leads the panel.
309369
searchBar
310370
.padding(.horizontal, HistoryUI.innerHorizontal)
371+
.padding(.top, 20)
311372
.padding(.bottom, 8)
312373

313374
Divider().opacity(0.6).padding(.bottom, 6)
@@ -355,6 +416,27 @@ struct ContentView: View {
355416
searchDebounceTask = task
356417
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: task)
357418
}
419+
.confirmationDialog(
420+
pendingClear == .all ? "Clear all items, including pinned?" : "Clear all unpinned items?",
421+
isPresented: Binding(
422+
get: { pendingClear != nil },
423+
set: { newValue in if !newValue { pendingClear = nil } }
424+
),
425+
titleVisibility: .visible
426+
) {
427+
Button(pendingClear == .all ? "Clear Everything" : "Clear Unpinned", role: .destructive) {
428+
let scope = pendingClear
429+
pendingClear = nil
430+
clearHistory(removePinned: scope == .all)
431+
}
432+
.keyboardShortcut(.defaultAction) // Enter / Return triggers the destructive action.
433+
Button("Cancel", role: .cancel) { pendingClear = nil }
434+
.keyboardShortcut(.cancelAction) // Esc cancels.
435+
} message: {
436+
Text(pendingClear == .all
437+
? "This permanently removes every clipboard entry, including pinned ones. This cannot be undone."
438+
: "This permanently removes all unpinned clipboard entries. Pinned items will be kept. This cannot be undone.")
439+
}
358440
}
359441

360442
private var emptyState: some View {
@@ -381,16 +463,32 @@ struct ContentView: View {
381463
.background(RoundedRectangle(cornerRadius: 9, style: .continuous).fill(Color(NSColor.controlBackgroundColor)))
382464
.overlay(RoundedRectangle(cornerRadius: 9, style: .continuous).stroke(Color.white.opacity(0.06)))
383465

384-
Button(role: .destructive) {
385-
let alsoPinned = NSEvent.modifierFlags.contains(.option)
386-
clearHistory(removePinned: alsoPinned)
387-
} label: {
388-
Image(systemName: "trash"); Text("Clear")
466+
if isMultiSelectMode {
467+
Button("Cancel") { exitSelectMode() }
468+
.font(.footnote)
469+
.controlSize(.small)
470+
Button(role: .destructive) {
471+
deleteMultiSelected()
472+
} label: {
473+
Image(systemName: "trash"); Text("Delete \(multiSelectedIDs.count)")
474+
}
475+
.font(.footnote)
476+
.buttonStyle(.bordered)
477+
.controlSize(.small)
478+
.disabled(multiSelectedIDs.isEmpty)
479+
.help("Delete the selected items")
480+
} else {
481+
Button(role: .destructive) {
482+
let alsoPinned = NSEvent.modifierFlags.contains(.option)
483+
pendingClear = alsoPinned ? .all : .unpinnedOnly
484+
} label: {
485+
Image(systemName: "trash"); Text("Clear")
486+
}
487+
.font(.footnote)
488+
.buttonStyle(.bordered)
489+
.controlSize(.small)
490+
.help("Clear unpinned items (⌥-click to include pinned)")
389491
}
390-
.font(.footnote)
391-
.buttonStyle(.bordered)
392-
.controlSize(.small)
393-
.help("Clear unpinned items (⌥-click to include pinned)")
394492
}
395493
}
396494

@@ -423,6 +521,7 @@ struct ContentView: View {
423521
if let id = selectedID { perform(action: .copy, id: id) }
424522
case KeyCode.escape:
425523
if previewItemID != nil { previewItemID = nil }
524+
else if isMultiSelectMode { exitSelectMode() }
426525
else if !searchText.isEmpty { searchText = "" }
427526
else { selectedID = nil; HistoryWindowController.shared.close() }
428527
default: break
@@ -436,6 +535,8 @@ struct ClipRow: View {
436535
let item: ClipItem
437536
var isSelected: Bool
438537
var isCopied: Bool
538+
var isMultiSelectMode: Bool = false
539+
var isMultiSelected: Bool = false
439540
@Binding var previewItemID: UUID?
440541
@EnvironmentObject var itemsVM: ItemsViewModel
441542
@State private var isHovered: Bool = false
@@ -515,10 +616,10 @@ struct ClipRow: View {
515616
}
516617
}) {
517618
Image(systemName: item.pinned ? "pin.fill" : "pin")
518-
.font(.system(size: 11, weight: .semibold))
619+
.font(.system(size: 14, weight: .semibold))
519620
.foregroundStyle(item.pinned ? Color.accentColor : .secondary)
520-
.padding(.horizontal, 6)
521-
.padding(.vertical, 4)
621+
.padding(.horizontal, 7)
622+
.padding(.vertical, 5)
522623
.background(
523624
Capsule(style: .continuous)
524625
.fill(
@@ -542,21 +643,22 @@ struct ClipRow: View {
542643
.help(item.pinned ? "Unpin" : "Pin")
543644
}
544645

545-
private var copyButton: some View {
546-
Button(action: { NSPasteboard.general.copyString(item.text) }) {
547-
Image(systemName: "doc.on.clipboard")
548-
.font(.system(size: 11, weight: .semibold))
549-
}
550-
.buttonStyle(.plain)
551-
.help("Copy to clipboard (no auto-paste). Click the row to copy and paste.")
552-
}
553-
646+
@ViewBuilder
554647
private var trailingActions: some View {
555-
HStack(spacing: 6) {
648+
if isMultiSelectMode {
649+
// In select mode the row's trailing space conveys selection state instead
650+
// of offering per-row actions (which would conflict with click-to-select).
651+
Image(systemName: isMultiSelected ? "checkmark.circle.fill" : "circle")
652+
.font(.system(size: 16, weight: .semibold))
653+
.foregroundStyle(isMultiSelected ? Color.accentColor : .secondary.opacity(0.6))
654+
.symbolRenderingMode(.hierarchical)
655+
.allowsHitTesting(false)
656+
} else {
657+
// Pin only — copy is redundant with the row tap (which auto-pastes) and the
658+
// right-click "Copy" menu. Removed the doc-on-clipboard glyph for visual quiet.
556659
pinButton
557-
copyButton
660+
.opacity(isHovered ? 1 : 0.75)
558661
}
559-
.opacity(isHovered ? 1 : 0.7)
560662
}
561663

562664
private var rowBackground: some View {
@@ -622,6 +724,7 @@ struct ClipRow: View {
622724
}
623725

624726
private var backgroundColor: Color {
727+
if isMultiSelected { return Color.accentColor.opacity(0.22) }
625728
if isCopied { return Color.green.opacity(0.15) }
626729
if isSelected { return Color.accentColor.opacity(0.14) }
627730
if isHovered { return Color.gray.opacity(0.06) }
@@ -672,6 +775,21 @@ private struct FullTextPopover: View {
672775
.frame(maxWidth: .infinity, alignment: .leading)
673776
.padding(Self.pad)
674777
}
778+
.scrollContentBackground(.hidden)
779+
// Glass / Liquid-Glass look — NSVisualEffectView with the system popover
780+
// material composites the user's wallpaper through with a subtle vibrancy,
781+
// matching macOS Tahoe's translucent surfaces.
782+
.background(
783+
VisualEffectView(material: .popover, blendingMode: .behindWindow, isEmphasized: false)
784+
.ignoresSafeArea()
785+
)
786+
.overlay(
787+
// Hairline edge so the glass surface has a defined boundary against the
788+
// host window. Subtle — adapts to dark/light via white-with-opacity.
789+
RoundedRectangle(cornerRadius: 10, style: .continuous)
790+
.stroke(Color.white.opacity(0.10), lineWidth: 1)
791+
.allowsHitTesting(false)
792+
)
675793
.frame(
676794
minWidth: Self.minW, idealWidth: idealWidth, maxWidth: Self.maxW,
677795
minHeight: Self.minH, idealHeight: idealHeight, maxHeight: Self.maxH

0 commit comments

Comments
 (0)