From 1e37169b3bb8561221b4b4eb00dff1d6bf989d0b Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Tue, 30 Jun 2026 09:54:59 -0700 Subject: [PATCH] WIP: add plain text action overflow rail --- docs/SMOKE_TEST.md | 1 + .../clipbored/views/ClipboardPanelView.swift | 138 ++++++++++++++---- .../ClipboardPanelViewTests.swift | 21 ++- 3 files changed, 131 insertions(+), 29 deletions(-) diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 7f25017..fefb357 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -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 diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 3c4c70d..0c4021c 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -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() + 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) } diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 442de1c..10f53a3 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -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))