WIP: add plain text action overflow rail

This commit is contained in:
Akshay Kolli
2026-06-30 09:54:59 -07:00
parent d64665bd98
commit 1e37169b3b
3 changed files with 131 additions and 29 deletions

View File

@@ -54,6 +54,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
21. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. 21. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
22. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected. 22. Drag an unassigned card onto the renamed collection chip and confirm the chip count increases and the card appears when that collection is selected.
23. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly. 23. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
24. Select a file, rich text, or URL card and confirm the selected-card rail exposes `Paste Plain Text`; on a narrow shelf, confirm secondary actions collapse behind `More` instead of overflowing the card.
## Copy And Paste ## Copy And Paste

View File

@@ -2320,6 +2320,7 @@ private final class AspectFillImageView: NSView {
private final class ClipboardItemCardView: NSView, NSDraggingSource { private final class ClipboardItemCardView: NSView, NSDraggingSource {
private enum Metrics { private enum Metrics {
static let dragThreshold: CGFloat = 4 static let dragThreshold: CGFloat = 4
static let actionRailHorizontalMargin: CGFloat = 12
} }
private enum Palette { private enum Palette {
static let border = NSColor.separatorColor.withAlphaComponent(0.20).cgColor static let border = NSColor.separatorColor.withAlphaComponent(0.20).cgColor
@@ -2331,6 +2332,28 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
static let divider = NSColor.separatorColor.withAlphaComponent(0.14).cgColor static let divider = NSColor.separatorColor.withAlphaComponent(0.14).cgColor
} }
private struct ActionRailButtonSpec {
let systemName: String
let toolTip: String
let action: Selector
let isPrimary: Bool
let overflowPriority: Int?
init(
_ systemName: String,
toolTip: String,
action: Selector,
isPrimary: Bool = false,
overflowPriority: Int? = nil
) {
self.systemName = systemName
self.toolTip = toolTip
self.action = action
self.isPrimary = isPrimary
self.overflowPriority = overflowPriority
}
}
var onSelect: (Int) -> Void = { _ in } var onSelect: (Int) -> Void = { _ in }
var onMoveSelection: (Int) -> Void = { _ in } var onMoveSelection: (Int) -> Void = { _ in }
var onPageSelection: (Int) -> Void = { _ in } var onPageSelection: (Int) -> Void = { _ in }
@@ -2868,42 +2891,95 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
actionRail.setContentHuggingPriority(.required, for: .horizontal) actionRail.setContentHuggingPriority(.required, for: .horizontal)
actionRail.setContentCompressionResistancePriority(.required, for: .horizontal) actionRail.setContentCompressionResistancePriority(.required, for: .horizontal)
let pinTitle = itemIsPinned ? "Unpin" : "Pin" let specs = fittedActionRailButtonSpecs(from: preferredActionRailButtonSpecs())
actionRailButtons = [ actionRailButtons = specs.map { spec in
cardActionButton("return", toolTip: "Paste", action: #selector(pasteFromMenu), isPrimary: true), cardActionButton(
cardActionButton("doc.on.doc", toolTip: "Copy", action: #selector(copyFromMenu)), spec.systemName,
cardActionButton(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu)) toolTip: spec.toolTip,
] action: spec.action,
actionRailButtons.append(cardActionButton("plus", toolTip: "Collect", action: #selector(showCollectionMenuFromAction(_:)))) isPrimary: spec.isPrimary
actionRailButtons.append(cardActionButton("square.stack.3d.up", toolTip: itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu))) )
if canEditText {
actionRailButtons.append(cardActionButton("pencil", toolTip: "Edit", action: #selector(editTextFromMenu)))
} }
if canPreview {
actionRailButtons.append(cardActionButton("eye", toolTip: "Preview", action: #selector(previewFromMenu)))
}
if canOpen {
actionRailButtons.append(cardActionButton("arrow.up.right.square", toolTip: "Open", action: #selector(openFromMenu)))
}
if canReveal {
actionRailButtons.append(cardActionButton("magnifyingglass", toolTip: "Reveal", action: #selector(revealFromMenu)))
}
actionRailButtons.append(cardActionButton("trash", toolTip: "Delete", action: #selector(deleteFromMenu)))
for button in actionRailButtons { for button in actionRailButtons {
actionRail.addArrangedSubview(button) actionRail.addArrangedSubview(button)
} }
let buttonCount = CGFloat(actionRailButtons.count) let contentWidth = actionRailWidth(for: specs)
let secondaryCount = CGFloat(max(0, actionRailButtons.count - 1))
let contentWidth = layout.primaryActionButtonSize
+ secondaryCount * layout.actionButtonSize
+ max(0, buttonCount - 1) * actionRail.spacing
+ actionRail.edgeInsets.left
+ actionRail.edgeInsets.right
actionRail.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true actionRail.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true
updateActionRailVisibility() updateActionRailVisibility()
} }
private func preferredActionRailButtonSpecs() -> [ActionRailButtonSpec] {
let pinTitle = itemIsPinned ? "Unpin" : "Pin"
var specs: [ActionRailButtonSpec] = [
ActionRailButtonSpec("return", toolTip: "Paste", action: #selector(pasteFromMenu), isPrimary: true),
ActionRailButtonSpec("doc.on.doc", toolTip: "Copy", action: #selector(copyFromMenu))
]
if canPlainText {
specs.append(ActionRailButtonSpec("textformat", toolTip: "Paste Plain Text", action: #selector(pastePlainTextFromMenu)))
specs.append(ActionRailButtonSpec("doc.plaintext", toolTip: "Copy Plain Text", action: #selector(copyPlainTextFromMenu), overflowPriority: 20))
}
specs.append(ActionRailButtonSpec(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu), overflowPriority: 30))
specs.append(ActionRailButtonSpec("plus", toolTip: "Collect", action: #selector(showCollectionMenuFromAction(_:))))
specs.append(ActionRailButtonSpec("square.stack.3d.up", toolTip: itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu)))
if canEditText {
specs.append(ActionRailButtonSpec("pencil", toolTip: "Edit", action: #selector(editTextFromMenu), overflowPriority: 50))
}
if canPreview {
specs.append(ActionRailButtonSpec("eye", toolTip: "Preview", action: #selector(previewFromMenu), overflowPriority: 60))
}
if canOpen {
specs.append(ActionRailButtonSpec("arrow.up.right.square", toolTip: "Open", action: #selector(openFromMenu), overflowPriority: 50))
}
if canReveal {
specs.append(ActionRailButtonSpec("magnifyingglass", toolTip: "Reveal", action: #selector(revealFromMenu), overflowPriority: 40))
}
specs.append(ActionRailButtonSpec("trash", toolTip: "Delete", action: #selector(deleteFromMenu), overflowPriority: 10))
return specs
}
private func fittedActionRailButtonSpecs(from specs: [ActionRailButtonSpec]) -> [ActionRailButtonSpec] {
let maximumWidth = layout.width - (Metrics.actionRailHorizontalMargin * 2)
guard actionRailWidth(for: specs) > maximumWidth else { return specs }
let moreSpec = ActionRailButtonSpec("ellipsis.circle", toolTip: "More", action: #selector(showMoreActionsFromActionRail(_:)))
let overflowCandidates = specs.enumerated().compactMap { index, spec -> (index: Int, priority: Int)? in
guard let priority = spec.overflowPriority else { return nil }
return (index, priority)
}.sorted { lhs, rhs in
lhs.priority == rhs.priority ? lhs.index > rhs.index : lhs.priority < rhs.priority
}
var hiddenIndexes = Set<Int>()
for candidate in overflowCandidates {
hiddenIndexes.insert(candidate.index)
let visibleSpecs = specs.enumerated().compactMap { index, spec in
hiddenIndexes.contains(index) ? nil : spec
} + [moreSpec]
if actionRailWidth(for: visibleSpecs) <= maximumWidth {
return visibleSpecs
}
}
return specs.enumerated().compactMap { index, spec in
hiddenIndexes.contains(index) ? nil : spec
} + [moreSpec]
}
private func actionRailWidth(for specs: [ActionRailButtonSpec]) -> CGFloat {
guard !specs.isEmpty else { return 0 }
let buttonWidth = specs.reduce(CGFloat(0)) { width, spec in
width + (spec.isPrimary ? layout.primaryActionButtonSize : layout.actionButtonSize)
}
return buttonWidth
+ CGFloat(max(0, specs.count - 1)) * actionRail.spacing
+ actionRail.edgeInsets.left
+ actionRail.edgeInsets.right
}
private func cardActionButton( private func cardActionButton(
_ systemName: String, _ systemName: String,
toolTip: String, toolTip: String,
@@ -2966,6 +3042,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
onCopyPlainText(index) onCopyPlainText(index)
} }
@objc private func showMoreActionsFromActionRail(_ sender: NSButton) {
contextMenu().popUp(
positioning: nil,
at: NSPoint(x: sender.bounds.minX, y: sender.bounds.minY - 4),
in: sender
)
}
@objc private func toggleStackFromMenu() { @objc private func toggleStackFromMenu() {
onToggleStack(index) onToggleStack(index)
} }

View File

@@ -373,12 +373,29 @@ final class ClipboardPanelViewTests: XCTestCase {
fixture.viewModel.selectFirstItem() fixture.viewModel.selectFirstItem()
XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file) XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file)
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Collect", "Add to Stack", "Preview", "Open", "Reveal", "Delete"]) XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Paste Plain Text", "Pin", "Collect", "Add to Stack", "Preview", "Open", "Reveal", "More"])
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 266) XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 294)
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden) XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden) XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
} }
func testCompactFileCardActionsFitInsideShelfWithOverflowMenu() {
let fixture = makePanelFixture()
fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true)
fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardDensity, "compact")
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Paste Plain Text", "Collect", "Add to Stack", "Preview", "Open", "More"])
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 222)
XCTAssertLessThanOrEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 240)
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Paste Plain Text", "Copy Plain Text", "Rename...", "Add to Stack", "Quick Look", "Pin", "Add to Collection", "Capture Rules", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
}
func testCardsAreKeyboardFocusableAndReturnPastesFocusedCard() { func testCardsAreKeyboardFocusableAndReturnPastesFocusedCard() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Older text card", store: fixture.store)) fixture.store.upsert(makeTextItem("Older text card", store: fixture.store))