WIP: add card stack corner control
This commit is contained in:
@@ -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.
|
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.
|
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.
|
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
|
## Copy And Paste
|
||||||
|
|
||||||
|
|||||||
@@ -1446,6 +1446,22 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
cardViews.first?.debugHeaderBadgeFrame ?? .zero
|
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 {
|
var debugResultCountText: String {
|
||||||
statusResultCountLabel.stringValue
|
statusResultCountLabel.stringValue
|
||||||
}
|
}
|
||||||
@@ -2412,6 +2428,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
private let footerSourceLabel = NSTextField(labelWithString: "")
|
private let footerSourceLabel = NSTextField(labelWithString: "")
|
||||||
private let footerDetailLabel = NSTextField(labelWithString: "")
|
private let footerDetailLabel = NSTextField(labelWithString: "")
|
||||||
private let actionRail = NSStackView()
|
private let actionRail = NSStackView()
|
||||||
|
private let stackCornerButton = NSButton()
|
||||||
private var actionRailButtons: [NSButton] = []
|
private var actionRailButtons: [NSButton] = []
|
||||||
private weak var headerBadgeView: NSView?
|
private weak var headerBadgeView: NSView?
|
||||||
private weak var headerPinView: NSView?
|
private weak var headerPinView: NSView?
|
||||||
@@ -2690,6 +2707,22 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return headerBadgeView.convert(headerBadgeView.bounds, to: self)
|
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? {
|
var debugQuickPasteBadgeText: String? {
|
||||||
quickPasteBadgeLabel?.stringValue
|
quickPasteBadgeLabel?.stringValue
|
||||||
}
|
}
|
||||||
@@ -3018,6 +3051,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
layout.isCompact ? 36 : 42
|
layout.isCompact ? 36 : 42
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var stackCornerButtonSize: CGFloat {
|
||||||
|
layout.isCompact ? 28 : 30
|
||||||
|
}
|
||||||
|
|
||||||
private var actionRailHeaderTopInset: CGFloat {
|
private var actionRailHeaderTopInset: CGFloat {
|
||||||
max(8, (layout.headerHeight - layout.actionRailHeight) / 2)
|
max(8, (layout.headerHeight - layout.actionRailHeight) / 2)
|
||||||
}
|
}
|
||||||
@@ -3058,11 +3095,40 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
return button
|
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() {
|
private func updateActionRailVisibility() {
|
||||||
actionRail.isHidden = !isSelected
|
actionRail.isHidden = !isSelected
|
||||||
headerBadgeView?.isHidden = false
|
headerBadgeView?.isHidden = false
|
||||||
headerPinView?.isHidden = isSelected
|
headerPinView?.isHidden = isSelected
|
||||||
footerDetailLabel.isHidden = false
|
footerDetailLabel.isHidden = false
|
||||||
|
stackCornerButton.isHidden = !(itemIsStacked || isSelected || isHovered || isKeyboardFocused)
|
||||||
|
stackCornerButton.alphaValue = itemIsStacked ? 1.0 : 0.94
|
||||||
for button in actionRailButtons {
|
for button in actionRailButtons {
|
||||||
button.alphaValue = 1.0
|
button.alphaValue = 1.0
|
||||||
}
|
}
|
||||||
@@ -3096,6 +3162,11 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
onToggleStack(index)
|
onToggleStack(index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc private func toggleStackFromCornerButton() {
|
||||||
|
onSelect(index)
|
||||||
|
onToggleStack(index)
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func pasteStackNextFromMenu() {
|
@objc private func pasteStackNextFromMenu() {
|
||||||
onPasteStackNext()
|
onPasteStackNext()
|
||||||
}
|
}
|
||||||
@@ -3240,6 +3311,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
body.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
body.widthAnchor.constraint(equalTo: stack.widthAnchor),
|
||||||
footer.widthAnchor.constraint(equalTo: stack.widthAnchor)
|
footer.widthAnchor.constraint(equalTo: stack.widthAnchor)
|
||||||
])
|
])
|
||||||
|
configureStackCornerButton()
|
||||||
|
contentView.addSubview(stackCornerButton)
|
||||||
contentView.addSubview(actionRail)
|
contentView.addSubview(actionRail)
|
||||||
let actionRailTrailingConstraint: NSLayoutConstraint
|
let actionRailTrailingConstraint: NSLayoutConstraint
|
||||||
if let headerBadgeView {
|
if let headerBadgeView {
|
||||||
@@ -3251,6 +3324,10 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
actionRailTrailingConstraint = actionRail.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12)
|
actionRailTrailingConstraint = actionRail.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12)
|
||||||
}
|
}
|
||||||
NSLayoutConstraint.activate([
|
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,
|
actionRailTrailingConstraint,
|
||||||
actionRail.topAnchor.constraint(equalTo: contentView.topAnchor, constant: actionRailHeaderTopInset)
|
actionRail.topAnchor.constraint(equalTo: contentView.topAnchor, constant: actionRailHeaderTopInset)
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -892,6 +892,36 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Remove from Stack", "Edit", "Preview", "Delete"])
|
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() {
|
func testStackChipAppearsFiltersAndClearsWithStack() {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
fixture.store.upsert(makeTextItem("First stack chip item", store: fixture.store))
|
fixture.store.upsert(makeTextItem("First stack chip item", store: fixture.store))
|
||||||
|
|||||||
Reference in New Issue
Block a user