From b84f35834a9a5f5c342b3f59cc1554afbc72b81d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96MER=20FARUK=20CO=C5=9EKUN?= Date: Thu, 25 Jun 2026 12:13:14 +0300 Subject: [PATCH] Add Viewer Guides overlay to preview canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ViewerGuide enum with 7 presets: Action Safe (93%), Title Safe (90%), Center crosshair, Scope (2.39:1), Wide (1.85:1), Square (1:1), Portrait (9:16) - ViewerGuidesOverlay renders guides via Canvas — zero layout cost, skipped entirely when no guides active - Guides toggle button (viewfinder icon) added left of the overflow menu in the tab bar - Toggle uses native SwiftUI Toggle+Binding so macOS checkmarks work correctly - Active guides persisted to UserDefaults keyed per project (viewerGuides.) Closes #167 --- .../Preview/PreviewContainerView.swift | 55 +++++++++++++ Sources/PalmierPro/Preview/ViewerGuide.swift | 46 +++++++++++ .../Preview/ViewerGuidesOverlay.swift | 82 +++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 Sources/PalmierPro/Preview/ViewerGuide.swift create mode 100644 Sources/PalmierPro/Preview/ViewerGuidesOverlay.swift diff --git a/Sources/PalmierPro/Preview/PreviewContainerView.swift b/Sources/PalmierPro/Preview/PreviewContainerView.swift index 1e7334e02..a1e71b70f 100644 --- a/Sources/PalmierPro/Preview/PreviewContainerView.swift +++ b/Sources/PalmierPro/Preview/PreviewContainerView.swift @@ -8,6 +8,20 @@ struct PreviewContainerView: View { private var isImage: Bool { editor.activePreviewTab.clipType == .image } @State private var hoveredTabId: String? + @State private var activeGuides: Set = [] + + private var guidesDefaultsKey: String { + "viewerGuides.\(editor.projectId ?? "default")" + } + + private func loadGuides() { + let saved = UserDefaults.standard.stringArray(forKey: guidesDefaultsKey) ?? [] + activeGuides = Set(saved.compactMap(ViewerGuide.init(rawValue:))) + } + + private func saveGuides() { + UserDefaults.standard.set(activeGuides.map(\.rawValue), forKey: guidesDefaultsKey) + } var body: some View { VStack(spacing: 0) { @@ -34,6 +48,9 @@ struct PreviewContainerView: View { if let overlay = offlineOverlay { offlinePreview(assetId: overlay.assetId, path: overlay.path, isUnprocessable: overlay.isUnprocessable) } + if !activeGuides.isEmpty { + ViewerGuidesOverlay(activeGuides: activeGuides) + } if editor.cropEditingActive { CropOverlayView() } else { @@ -57,6 +74,9 @@ struct PreviewContainerView: View { } } .background(AppTheme.Background.surfaceColor) + .onAppear { loadGuides() } + .onChange(of: editor.projectId) { loadGuides() } + .onChange(of: activeGuides) { saveGuides() } } // MARK: - Transport bar @@ -554,6 +574,7 @@ struct PreviewContainerView: View { } } + guidesToggleButton overflowMenu } } @@ -627,6 +648,40 @@ struct PreviewContainerView: View { .help("More") } + private var guidesToggleButton: some View { + Menu { + Section("Safe Zones") { + ForEach([ViewerGuide.actionSafe, .titleSafe, .center]) { guide in + Toggle(guide.displayName, isOn: guideBinding(for: guide)) + } + } + Section("Format Reference") { + ForEach([ViewerGuide.scope, .wide, .square, .portrait]) { guide in + Toggle(guide.displayName, isOn: guideBinding(for: guide)) + } + } + } label: { + Image(systemName: activeGuides.isEmpty ? "viewfinder" : "viewfinder.circle.fill") + .font(.system(size: AppTheme.FontSize.sm, weight: .medium)) + .foregroundStyle(activeGuides.isEmpty ? AppTheme.Text.secondaryColor : AppTheme.Accent.primary) + .frame(width: AppTheme.IconSize.md, height: AppTheme.IconSize.md) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .hoverHighlight(cornerRadius: AppTheme.Radius.sm) + .help("Viewer Guides") + } + + private func guideBinding(for guide: ViewerGuide) -> Binding { + Binding( + get: { activeGuides.contains(guide) }, + set: { on in + if on { activeGuides.insert(guide) } else { activeGuides.remove(guide) } + } + ) + } + private func closeButton(tabId: String) -> some View { Button { withAnimation(.easeInOut(duration: AppTheme.Anim.transition)) { diff --git a/Sources/PalmierPro/Preview/ViewerGuide.swift b/Sources/PalmierPro/Preview/ViewerGuide.swift new file mode 100644 index 000000000..f1e4f215e --- /dev/null +++ b/Sources/PalmierPro/Preview/ViewerGuide.swift @@ -0,0 +1,46 @@ +import Foundation + +enum ViewerGuide: String, CaseIterable, Identifiable { + case actionSafe + case titleSafe + case center + case scope + case wide + case square + case portrait + + var id: String { rawValue } + + var displayName: String { + switch self { + case .actionSafe: return "Action Safe" + case .titleSafe: return "Title Safe" + case .center: return "Center" + case .scope: return "Scope (2.39:1)" + case .wide: return "Wide (1.85:1)" + case .square: return "Square (1:1)" + case .portrait: return "Portrait (9:16)" + } + } + + // Inset fraction per side for safe-zone guides (SMPTE ST 2046-1 / ITU-R BT.1848-1). + // Action Safe = 93% of frame → 3.5% inset each side. + // Title Safe = 90% of frame → 5% inset each side. + var safeZoneInset: CGFloat? { + switch self { + case .actionSafe: return 0.035 + case .titleSafe: return 0.05 + default: return nil + } + } + + var formatAspect: CGFloat? { + switch self { + case .scope: return 2.39 + case .wide: return 1.85 + case .square: return 1.0 + case .portrait: return 9.0 / 16.0 + default: return nil + } + } +} diff --git a/Sources/PalmierPro/Preview/ViewerGuidesOverlay.swift b/Sources/PalmierPro/Preview/ViewerGuidesOverlay.swift new file mode 100644 index 000000000..988b3c3da --- /dev/null +++ b/Sources/PalmierPro/Preview/ViewerGuidesOverlay.swift @@ -0,0 +1,82 @@ +import SwiftUI + +struct ViewerGuidesOverlay: View { + let activeGuides: Set + + var body: some View { + Canvas { ctx, size in + for guide in activeGuides { + switch guide { + case .actionSafe, .titleSafe: + drawSafeZone(guide, ctx: &ctx, size: size) + case .center: + drawCenter(ctx: &ctx, size: size) + case .scope, .wide, .square, .portrait: + drawFormatBars(guide, ctx: &ctx, size: size) + } + } + } + .allowsHitTesting(false) + } + + private func drawSafeZone(_ guide: ViewerGuide, ctx: inout GraphicsContext, size: CGSize) { + guard let inset = guide.safeZoneInset else { return } + let dx = size.width * inset + let dy = size.height * inset + let rect = CGRect(x: dx, y: dy, width: size.width - dx * 2, height: size.height - dy * 2) + var path = Path() + path.addRect(rect) + ctx.stroke( + path, + with: .color(.white.opacity(AppTheme.Opacity.strong)), + style: StrokeStyle(lineWidth: AppTheme.BorderWidth.thin, dash: [4, 3]) + ) + } + + private func drawCenter(ctx: inout GraphicsContext, size: CGSize) { + let cx = size.width / 2 + let cy = size.height / 2 + let arm: CGFloat = 14 + var path = Path() + path.move(to: CGPoint(x: cx - arm, y: cy)) + path.addLine(to: CGPoint(x: cx + arm, y: cy)) + path.move(to: CGPoint(x: cx, y: cy - arm)) + path.addLine(to: CGPoint(x: cx, y: cy + arm)) + ctx.stroke(path, with: .color(.white.opacity(AppTheme.Opacity.strong)), lineWidth: AppTheme.BorderWidth.thin) + } + + private func drawFormatBars(_ guide: ViewerGuide, ctx: inout GraphicsContext, size: CGSize) { + guard let target = guide.formatAspect else { return } + let current = size.width / size.height + guard abs(current - target) > 0.01 else { return } + + let barFill = GraphicsContext.Shading.color(.black.opacity(AppTheme.Opacity.strong)) + let edge = GraphicsContext.Shading.color(.white.opacity(AppTheme.Opacity.medium)) + + if target < current { + // Pillarbox: black bars on left and right + let innerW = size.height * target + let barW = (size.width - innerW) / 2 + ctx.fill(Path(CGRect(x: 0, y: 0, width: barW, height: size.height)), with: barFill) + ctx.fill(Path(CGRect(x: size.width - barW, y: 0, width: barW, height: size.height)), with: barFill) + var border = Path() + border.move(to: CGPoint(x: barW, y: 0)) + border.addLine(to: CGPoint(x: barW, y: size.height)) + border.move(to: CGPoint(x: size.width - barW, y: 0)) + border.addLine(to: CGPoint(x: size.width - barW, y: size.height)) + ctx.stroke(border, with: edge, lineWidth: AppTheme.BorderWidth.thin) + } else { + // Letterbox: black bars on top and bottom + let innerH = size.width / target + let barH = (size.height - innerH) / 2 + ctx.fill(Path(CGRect(x: 0, y: 0, width: size.width, height: barH)), with: barFill) + ctx.fill(Path(CGRect(x: 0, y: size.height - barH, width: size.width, height: barH)), with: barFill) + var border = Path() + border.move(to: CGPoint(x: 0, y: barH)) + border.addLine(to: CGPoint(x: size.width, y: barH)) + border.move(to: CGPoint(x: 0, y: size.height - barH)) + border.addLine(to: CGPoint(x: size.width, y: size.height - barH)) + ctx.stroke(border, with: edge, lineWidth: AppTheme.BorderWidth.thin) + } + } +}