From 152eb4cbbbc8ec5460d5ac731753ce184c80f8a1 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Tue, 30 Jun 2026 10:30:07 -0700 Subject: [PATCH] WIP: add card stack corner control --- docs/SMOKE_TEST.md | 1 + .../clipbored/views/ClipboardPanelView.swift | 77 +++++++++++++++++++ .../ClipboardPanelViewTests.swift | 30 ++++++++ 3 files changed, 108 insertions(+) diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 31958c0..f78b922 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -57,6 +57,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti 24. Select a file, rich text, or URL card and confirm the selected-card rail exposes `Paste Plain Text`, the corner source/kind badge remains visible, and on a narrow shelf secondary actions collapse behind `More` instead of overflowing the card. 25. Confirm card footers do not show `Unknown` for clips without a source app, and confirm used clips show their usage count beside the source app. 26. Confirm card headers use readable relative ages such as `3 minutes ago` or `2 hours ago`, including when viewing a named collection. +27. Confirm the selected card shows a green corner Stack control, and that clips added to Stack keep a visible corner indicator when selection moves away. ## Copy And Paste diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 7e3b5e4..d63a1f2 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -1446,6 +1446,22 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { cardViews.first?.debugHeaderBadgeFrame ?? .zero } + var debugStackCornerLabels: [String] { + cardViews.map(\.debugStackCornerLabel) + } + + var debugStackCornerHiddenStates: [Bool] { + cardViews.map(\.debugStackCornerIsHidden) + } + + var debugFirstCardStackCornerFrame: NSRect { + cardViews.first?.debugStackCornerFrame ?? .zero + } + + func debugPressFirstCardStackCornerButton() { + cardViews.first?.debugPressStackCornerButton() + } + var debugResultCountText: String { statusResultCountLabel.stringValue } @@ -2412,6 +2428,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { private let footerSourceLabel = NSTextField(labelWithString: "") private let footerDetailLabel = NSTextField(labelWithString: "") private let actionRail = NSStackView() + private let stackCornerButton = NSButton() private var actionRailButtons: [NSButton] = [] private weak var headerBadgeView: NSView? private weak var headerPinView: NSView? @@ -2690,6 +2707,22 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return headerBadgeView.convert(headerBadgeView.bounds, to: self) } + var debugStackCornerLabel: String { + stackCornerButton.toolTip ?? "" + } + + var debugStackCornerIsHidden: Bool { + stackCornerButton.isHidden + } + + var debugStackCornerFrame: NSRect { + stackCornerButton.convert(stackCornerButton.bounds, to: self) + } + + func debugPressStackCornerButton() { + stackCornerButton.performClick(nil) + } + var debugQuickPasteBadgeText: String? { quickPasteBadgeLabel?.stringValue } @@ -3018,6 +3051,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { layout.isCompact ? 36 : 42 } + private var stackCornerButtonSize: CGFloat { + layout.isCompact ? 28 : 30 + } + private var actionRailHeaderTopInset: CGFloat { max(8, (layout.headerHeight - layout.actionRailHeight) / 2) } @@ -3058,11 +3095,40 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { return button } + private func configureStackCornerButton() { + let toolTip = itemIsStacked ? "Remove from Stack" : "Add to Stack" + let image = NSImage(systemSymbolName: itemIsStacked ? "checkmark" : "plus", accessibilityDescription: toolTip) + image?.isTemplate = true + stackCornerButton.image = image + stackCornerButton.imagePosition = .imageOnly + stackCornerButton.imageScaling = .scaleProportionallyDown + stackCornerButton.isBordered = false + stackCornerButton.wantsLayer = true + stackCornerButton.layer?.cornerRadius = stackCornerButtonSize / 2 + stackCornerButton.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.94).cgColor + stackCornerButton.layer?.borderWidth = 1 + stackCornerButton.layer?.borderColor = NSColor.white.withAlphaComponent(0.82).cgColor + stackCornerButton.layer?.shadowColor = NSColor.black.cgColor + stackCornerButton.layer?.shadowOpacity = 0.22 + stackCornerButton.layer?.shadowRadius = 7 + stackCornerButton.layer?.shadowOffset = NSSize(width: 0, height: 3) + stackCornerButton.contentTintColor = .white + stackCornerButton.toolTip = toolTip + stackCornerButton.setAccessibilityLabel(toolTip) + stackCornerButton.target = self + stackCornerButton.action = #selector(toggleStackFromCornerButton) + stackCornerButton.translatesAutoresizingMaskIntoConstraints = false + stackCornerButton.setContentHuggingPriority(.required, for: .horizontal) + stackCornerButton.setContentCompressionResistancePriority(.required, for: .horizontal) + } + private func updateActionRailVisibility() { actionRail.isHidden = !isSelected headerBadgeView?.isHidden = false headerPinView?.isHidden = isSelected footerDetailLabel.isHidden = false + stackCornerButton.isHidden = !(itemIsStacked || isSelected || isHovered || isKeyboardFocused) + stackCornerButton.alphaValue = itemIsStacked ? 1.0 : 0.94 for button in actionRailButtons { button.alphaValue = 1.0 } @@ -3096,6 +3162,11 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { onToggleStack(index) } + @objc private func toggleStackFromCornerButton() { + onSelect(index) + onToggleStack(index) + } + @objc private func pasteStackNextFromMenu() { onPasteStackNext() } @@ -3240,6 +3311,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { body.widthAnchor.constraint(equalTo: stack.widthAnchor), footer.widthAnchor.constraint(equalTo: stack.widthAnchor) ]) + configureStackCornerButton() + contentView.addSubview(stackCornerButton) contentView.addSubview(actionRail) let actionRailTrailingConstraint: NSLayoutConstraint if let headerBadgeView { @@ -3251,6 +3324,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource { actionRailTrailingConstraint = actionRail.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12) } NSLayoutConstraint.activate([ + stackCornerButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12), + stackCornerButton.centerYAnchor.constraint(equalTo: footer.topAnchor), + stackCornerButton.widthAnchor.constraint(equalToConstant: stackCornerButtonSize), + stackCornerButton.heightAnchor.constraint(equalToConstant: stackCornerButtonSize), actionRailTrailingConstraint, actionRail.topAnchor.constraint(equalTo: contentView.topAnchor, constant: actionRailHeaderTopInset) ]) diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index c0aea82..d1a0f3a 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -892,6 +892,36 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Remove from Stack", "Edit", "Preview", "Delete"]) } + func testStackCornerButtonTogglesAndPersistsForQueuedCards() { + let fixture = makePanelFixture() + fixture.store.upsert(makeTextItem("Older stack item", store: fixture.store)) + fixture.store.upsert(makeTextItem("Newest stack item", store: fixture.store)) + drainMainQueue() + fixture.viewModel.selectItem(at: 0) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Add to Stack", "Add to Stack"]) + XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [false, true]) + XCTAssertGreaterThan(fixture.view.debugFirstCardStackCornerFrame.maxX, 290) + + fixture.view.debugPressFirstCardStackCornerButton() + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugStatusText, "Added to Stack") + XCTAssertEqual(fixture.view.debugStackCornerLabels, ["Remove from Stack", "Add to Stack"]) + XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [false, true]) + XCTAssertTrue(fixture.view.debugStackChipIsVisible) + XCTAssertEqual(fixture.view.debugStackChipCount, 1) + + fixture.viewModel.selectItem(at: 1) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugStackCornerHiddenStates, [false, false]) + } + func testStackChipAppearsFiltersAndClearsWithStack() { let fixture = makePanelFixture() fixture.store.upsert(makeTextItem("First stack chip item", store: fixture.store))