From 2471c5a39d281037c3f0798cd18ae817f54ae7da Mon Sep 17 00:00:00 2001 From: yoshikazu Date: Mon, 27 Jul 2026 15:42:42 +0000 Subject: [PATCH] Add photo tile view --- Sources/Photochk/Views/PhotoTileView.swift | 91 ++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 Sources/Photochk/Views/PhotoTileView.swift diff --git a/Sources/Photochk/Views/PhotoTileView.swift b/Sources/Photochk/Views/PhotoTileView.swift new file mode 100644 index 0000000..c854831 --- /dev/null +++ b/Sources/Photochk/Views/PhotoTileView.swift @@ -0,0 +1,91 @@ +import AppKit +import SwiftUI + +struct PhotoTileView: View { + let member: SimilarityGroup.Member + let isRepresentative: Bool + let isMarked: Bool + let canMark: Bool + let onToggle: () -> Void + + private let thumbnailSize: CGFloat = 168 + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .topLeading) { + ThumbnailView(url: member.id, size: thumbnailSize) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .strokeBorder(borderColor, lineWidth: isMarked ? 3 : 1) + } + .opacity(isMarked ? 0.55 : 1) + + Toggle(isOn: Binding(get: { isMarked }, set: { _ in onToggle() })) { + EmptyView() + } + .toggleStyle(.checkbox) + .labelsHidden() + .disabled(!canMark) + // 明るい写真の上でもチェックボックスが埋もれないように下敷きを敷く + .padding(4) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 5, style: .continuous)) + .padding(6) + .help(canMark ? "ゴミ箱へ移動する対象にする" : "グループから最低1枚は残す必要があります") + + if isRepresentative { + Text("残す候補") + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.accentColor, in: Capsule()) + .foregroundStyle(.white) + .padding(6) + .frame(maxWidth: thumbnailSize, alignment: .topTrailing) + } + } + + Text(member.asset.fileName) + .font(.callout) + .lineLimit(1) + .truncationMode(.middle) + + VStack(alignment: .leading, spacing: 1) { + Text("\(member.asset.dimensionText)・\(member.asset.fileSizeText)") + Text(member.asset.captureDateText) + + if isRepresentative { + Text("この写真が基準") + } else if let tag = member.exactSetTag { + // 代表とは別だが、同じ印どうしは中身が完全に一致している + Text("\(member.relation.distanceText)・同一 \(tag)") + .foregroundStyle(.orange) + } else { + Text(member.relation.distanceText) + .foregroundStyle(member.relation.isExact ? .orange : .secondary) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(width: thumbnailSize) + .contentShape(.rect) + // クリックは選択に一本化する。ダブルクリックを併置すると単クリックと取り合いになり、 + // チェックが二度切り替わってしまうため、プレビューは右クリックメニューから開く。 + .onTapGesture { if canMark { onToggle() } } + .contextMenu { + Button("プレビューで開く") { NSWorkspace.shared.open(member.id) } + Button("Finder で表示") { + NSWorkspace.shared.activateFileViewerSelecting([member.id]) + } + Button("パスをコピー") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(member.id.path, forType: .string) + } + } + } + + private var borderColor: Color { + if isMarked { return .red } + return isRepresentative ? .accentColor : Color.primary.opacity(0.12) + } +}