@@ -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