From 9666308c8eefe22ec25cbbbb56d656937f33c46e Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Mon, 5 Oct 2026 06:44:31 +0800 Subject: [PATCH] feat(assistant): animate copy confirmation icons --- .../AssistantCompactToolDetails.swift | 2 +- .../AssistantCopyButton.swift | 47 +++++++++++++++++ .../AssistantToolDetails.swift | 2 +- .../AI/MobileAssistantConversation.swift | 12 +++-- TFM/Features/AI/TranscriptNativeRows.swift | 50 +++++++++++++++++-- 5 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 Frameworks/TermindPresentation/AssistantCopyButton.swift diff --git a/Frameworks/TermindPresentation/AssistantCompactToolDetails.swift b/Frameworks/TermindPresentation/AssistantCompactToolDetails.swift index d0e6c08e..6adf245a 100644 --- a/Frameworks/TermindPresentation/AssistantCompactToolDetails.swift +++ b/Frameworks/TermindPresentation/AssistantCompactToolDetails.swift @@ -36,7 +36,7 @@ public struct AssistantCompactToolDetails: View { expanded ? AppStrings.Assistant.showLess.swiftUIKey : AppStrings.Assistant.showMore.swiftUIKey ) { expanded.toggle() } .accessibilityIdentifier("AssistantTool.More") - Button(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") { copy(preview.fullText) } + AssistantCopyButton(text: preview.fullText, copy: copy) .accessibilityIdentifier("AssistantTool.Copy") } .font(.caption) diff --git a/Frameworks/TermindPresentation/AssistantCopyButton.swift b/Frameworks/TermindPresentation/AssistantCopyButton.swift new file mode 100644 index 00000000..a94765c0 --- /dev/null +++ b/Frameworks/TermindPresentation/AssistantCopyButton.swift @@ -0,0 +1,47 @@ +import SwiftUI + +public struct AssistantCopyButton: View { + private let text: String + private let minimumLabelSize: CGFloat? + private let copy: (String) -> Void + @State private var copied = false + @State private var copyID = UUID() + + public init(text: String, minimumLabelSize: CGFloat? = nil, copy: @escaping (String) -> Void) { + self.text = text + self.minimumLabelSize = minimumLabelSize + self.copy = copy + } + + public var body: some View { + Button { + copy(text) + copied = true + copyID = UUID() + } label: { + Label { + Text(AppStrings.Common.copy) + } icon: { + // Keep the copy glyph's layout footprint when the narrower checkmark appears. + Image(systemName: "doc.on.doc") + .hidden() + .overlay { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .contentTransition(.symbolEffect(.replace)) + } + } + .frame(minWidth: minimumLabelSize, minHeight: minimumLabelSize) + .contentShape(.rect) + } + .accessibilityValue(Text(copied ? AppStrings.Common.copied : AppStrings.Common.copy)) + .task(id: copyID) { + guard copied else { return } + do { try await Task.sleep(for: .seconds(1.4)) } catch { return } + copied = false + } + .onChange(of: text) { _, _ in + copied = false + copyID = UUID() + } + } +} diff --git a/Frameworks/TermindPresentation/AssistantToolDetails.swift b/Frameworks/TermindPresentation/AssistantToolDetails.swift index 0409b23b..c6a93318 100644 --- a/Frameworks/TermindPresentation/AssistantToolDetails.swift +++ b/Frameworks/TermindPresentation/AssistantToolDetails.swift @@ -70,7 +70,7 @@ public struct AssistantToolDetails: View { HStack { Text(AppStrings.Assistant.outputShortenedForDisplay).font(.caption2).foregroundStyle(.secondary) if let copy { - Button(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") { copy(preview.full) } + AssistantCopyButton(text: preview.full, copy: copy) .font(.caption).frame(minHeight: 44) } } diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index 851ffac2..576bab73 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -681,8 +681,8 @@ struct MobileAssistantConversation: View { .foregroundStyle(.red) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) - Button(AppStrings.Common.copy.swiftUIKey, systemImage: "doc.on.doc") { - UIPasteboard.general.string = failure.reason + AssistantCopyButton(text: failure.reason) { + UIPasteboard.general.string = $0 } .labelStyle(.iconOnly) .buttonStyle(.plain) @@ -809,9 +809,13 @@ struct MobileAssistantConversation: View { } } - messageAction(AppStrings.Common.copy, systemImage: "doc.on.doc") { - UIPasteboard.general.string = message.text + AssistantCopyButton(text: message.text, minimumLabelSize: 44) { + UIPasteboard.general.string = $0 } + .font(.caption) + .labelStyle(.iconOnly) + .buttonStyle(.plain) + .foregroundStyle(.secondary) if let origin = message.origin { let presentation = MobileAssistantAgentOriginPresentation( diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index be4f1837..a03b0597 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -2813,8 +2813,26 @@ private class NativeButton: NSButton { @objc private func run() { handler?() } } +@MainActor +private final class NativeCopyButtonCell: NSButtonCell { + // The image view draws the symbol so it can use the system replacement transition. + // Retain the cell's image and metrics for the existing button layout. + override func drawImage(_ image: NSImage, withFrame frame: NSRect, in controlView: NSView) {} +} + +@MainActor +private final class NativeCopySymbolView: NSImageView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + @MainActor private final class NativeCopyButton: NativeButton { + override class var cellClass: AnyClass? { + get { NativeCopyButtonCell.self } + set {} + } + + private let symbolView = NativeCopySymbolView() private let copiedImage = NSImage( systemSymbolName: "checkmark", accessibilityDescription: String(localized: AppStrings.Common.copied) @@ -2831,16 +2849,28 @@ private final class NativeCopyButton: NativeButton { image = copyImage imagePosition = .imageOnly toolTip = help + setAccessibilityLabel(help) widthAnchor.constraint(equalToConstant: 28).isActive = true heightAnchor.constraint(equalToConstant: 28).isActive = true + symbolView.image = copyImage + symbolView.imageScaling = .scaleNone + symbolView.symbolConfiguration = NSImage.SymbolConfiguration( + pointSize: NSFont.systemFontSize(for: controlSize), + weight: .regular + ) + symbolView.setAccessibilityElement(false) + addSubview(symbolView) handler = { [weak self] in guard copyToPasteboard(text), let self else { return } - image = copiedImage + showSymbol(copiedImage) toolTip = String(localized: AppStrings.Common.copied) + setAccessibilityValue(toolTip) resetWorkItem?.cancel() let work = DispatchWorkItem { [weak self] in - self?.image = self?.copyImage - self?.toolTip = String(localized: AppStrings.Common.copy) + guard let self else { return } + showSymbol(copyImage) + toolTip = String(localized: AppStrings.Common.copy) + setAccessibilityValue(nil) } resetWorkItem = work DispatchQueue.main.asyncAfter(deadline: .now() + 1.4, execute: work) @@ -2848,4 +2878,18 @@ private final class NativeCopyButton: NativeButton { } @available(*, unavailable) required init?(coder: NSCoder) { fatalError() } + + override func layout() { + super.layout() + symbolView.frame = bounds + } + + private func showSymbol(_ image: NSImage?) { + guard let image, symbolView.image !== image else { return } + if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { + symbolView.image = image + } else { + symbolView.setSymbolImage(image, contentTransition: .replace) + } + } }