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

View File

@@ -2320,6 +2320,7 @@ private final class AspectFillImageView: NSView {
private final class ClipboardItemCardView: NSView, NSDraggingSource {
private enum Metrics {
static let dragThreshold: CGFloat = 4
static let actionRailHorizontalMargin: CGFloat = 12
}
private enum Palette {
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
}
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 onMoveSelection: (Int) -> Void = { _ in }
var onPageSelection: (Int) -> Void = { _ in }
@@ -2868,42 +2891,95 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
actionRail.setContentHuggingPriority(.required, for: .horizontal)
actionRail.setContentCompressionResistancePriority(.required, for: .horizontal)
let pinTitle = itemIsPinned ? "Unpin" : "Pin"
actionRailButtons = [
cardActionButton("return", toolTip: "Paste", action: #selector(pasteFromMenu), isPrimary: true),
cardActionButton("doc.on.doc", toolTip: "Copy", action: #selector(copyFromMenu)),
cardActionButton(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu))
]
actionRailButtons.append(cardActionButton("plus", toolTip: "Collect", action: #selector(showCollectionMenuFromAction(_:))))
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)))
let specs = fittedActionRailButtonSpecs(from: preferredActionRailButtonSpecs())
actionRailButtons = specs.map { spec in
cardActionButton(
spec.systemName,
toolTip: spec.toolTip,
action: spec.action,
isPrimary: spec.isPrimary
)
}
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 {
actionRail.addArrangedSubview(button)
}
let buttonCount = CGFloat(actionRailButtons.count)
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
let contentWidth = actionRailWidth(for: specs)
actionRail.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true
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(
_ systemName: String,
toolTip: String,
@@ -2966,6 +3042,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
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() {
onToggleStack(index)
}

View File

@@ -373,12 +373,29 @@ final class ClipboardPanelViewTests: XCTestCase {
fixture.viewModel.selectFirstItem()
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.debugFirstCardVisibleActionRailWidth, 266)
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Paste Plain Text", "Pin", "Collect", "Add to Stack", "Preview", "Open", "Reveal", "More"])
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 294)
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
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() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Older text card", store: fixture.store))