diff --git a/App/SnapGlass/EditorTests/CanvasTextEntryTests.swift b/App/SnapGlass/EditorTests/CanvasTextEntryTests.swift index 8e9c2f0..bfc7aaa 100644 --- a/App/SnapGlass/EditorTests/CanvasTextEntryTests.swift +++ b/App/SnapGlass/EditorTests/CanvasTextEntryTests.swift @@ -4,14 +4,14 @@ import Testing @MainActor struct CanvasTextEntryTests { - private func image() throws -> CGImage { + private func image(width: Int = 800, height: Int = 600) throws -> CGImage { let context = try #require( CGContext( - data: nil, width: 800, height: 600, bitsPerComponent: 8, + data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) context.setFillColor(NSColor.white.cgColor) - context.fill(CGRect(x: 0, y: 0, width: 800, height: 600)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) return try #require(context.makeImage()) } @@ -102,6 +102,113 @@ struct CanvasTextEntryTests { #expect(canvas.canvasTextEditor == nil) } + @Test func activeCanvasEditorAppliesStyleUpdatesWithoutReplacingDraft() throws { + let canvas = EditableAnnotationCanvasNSView(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) + canvas.image = try image() + let session = UUID() + let node = AnnotationNode( + tool: .text, color: NSColor.red.cgColor, + points: [CGPoint(x: 0.2, y: 0.3)], text: "Original", fontSize: 24) + canvas.updateTextEntry(id: session, node: node, onCommit: { _ in }, onCancel: {}) + let editor = try #require(canvas.canvasTextEditor) + editor.string = "Live draft" + editor.setSelectedRange(NSRange(location: 4, length: 0)) + + var updated = node + updated.color = NSColor.blue.cgColor + updated.fillColor = NSColor.black.withAlphaComponent(0.4).cgColor + updated.opacity = 0.6 + updated.fontName = "Menlo" + updated.fontSize = 48 + updated.textAlignment = .trailing + canvas.updateTextEntry(id: session, node: updated, onCommit: { _ in }, onCancel: {}) + + #expect(canvas.canvasTextEditor === editor) + #expect(editor.string == "Live draft") + #expect(editor.selectedRange() == NSRange(location: 4, length: 0)) + #expect(editor.sourceNode?.fontSize == 48) + #expect(editor.font?.pointSize == 48) + #expect(editor.font?.familyName == NSFont(name: "Menlo", size: 48)?.familyName) + #expect(editor.alignment == .right) + #expect(editor.drawsBackground) + #expect(editor.textColor?.usingColorSpace(.sRGB)?.blueComponent ?? 0 > 0.8) + } + + @Test func editedTextStyleIsDraftedUntilCommitAndCancelRestoresInspector() throws { + let model = try model() + model.beginTextEntry(at: CGPoint(x: 0.2, y: 0.3)) + model.textDraft = "Original" + model.commitTextEntry() + let original = try #require(model.document?.nodes.first) + model.beginTextEditing(original) + + model.fontName = "Menlo" + model.fontSize = 48 + model.textAlignment = .trailing + model.annotationOpacity = 0.6 + model.fillEnabled = true + model.fillColor = .black + model.updateSelectedStyle() + + let draft = try #require(model.pendingTextNode) + #expect(draft.fontName == "Menlo") + #expect(draft.fontSize == 48) + #expect(draft.textAlignment == .trailing) + #expect(draft.opacity == 0.6) + #expect(draft.fillColor != nil) + #expect(model.document?.nodes.first?.fontSize == 24) + + model.cancelTextEntry() + #expect(model.document?.nodes.first?.fontSize == 24) + #expect(model.fontSize == 24) + + model.beginTextEditing(original) + model.textDraft = "Updated" + model.fontName = "Menlo" + model.fontSize = 48 + model.textAlignment = .trailing + model.commitTextEntry() + #expect(model.document?.nodes.first?.text == "Updated") + #expect(model.document?.nodes.first?.fontName == "Menlo") + #expect(model.document?.nodes.first?.fontSize == 48) + #expect(model.document?.nodes.first?.textAlignment == .trailing) + + model.undo() + #expect(model.document?.nodes.first?.text == "Original") + #expect(model.document?.nodes.first?.fontSize == 24) + } + + @Test func fittedLongTextAccountsForWrappingAtImageWidth() throws { + let model = EditorViewModel() + let narrowImage = try image(width: 160, height: 600) + model.document = model.interactor.createDocument(from: narrowImage) + model.beginTextEntry(at: .zero) + model.fontSize = 24 + model.textDraft = Array(repeating: "wrapping", count: 12).joined(separator: " ") + model.commitTextEntry() + + let node = try #require(model.document?.nodes.first) + #expect(node.normalizedRect.width <= 1) + #expect(node.normalizedRect.height > 0.2) + } + + @Test func smallTextContainerIncludesAllLaidOutGlyphs() throws { + let canvas = EditableAnnotationCanvasNSView(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) + canvas.image = try image() + let node = AnnotationNode( + tool: .text, color: NSColor.red.cgColor, + points: [CGPoint(x: 0.2, y: 0.3)], text: "Small 中文", fontSize: 24) + canvas.updateTextEntry(id: UUID(), node: node, onCommit: { _ in }, onCancel: {}) + let editor = try #require(canvas.canvasTextEditor) + let container = try #require(editor.textContainer) + let layoutManager = try #require(editor.layoutManager) + layoutManager.ensureLayout(for: container) + let usedHeight = layoutManager.usedRect(for: container).height + + #expect(container.containerSize.height >= ceil(usedHeight) + 1) + #expect(editor.bounds.height >= ceil(usedHeight) + editor.textContainerInset.height * 2 + 1) + } + @Test func nativeReturnModesAndEscape() throws { for newline in [false, true] { let editor = CanvasTextEditor(frame: CGRect(x: 0, y: 0, width: 300, height: 100)) diff --git a/App/SnapGlass/EditorTests/EditorHistoryTests.swift b/App/SnapGlass/EditorTests/EditorHistoryTests.swift new file mode 100644 index 0000000..b6534e9 --- /dev/null +++ b/App/SnapGlass/EditorTests/EditorHistoryTests.swift @@ -0,0 +1,84 @@ +import AnnotationCore +import AppKit +import HistoryCore +import Testing + +@testable import EditorInteractionTests + +final class HistoryRequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var entries: [(mode: EditorHistorySaveMode, source: UUID?)] = [] + + func append(_ mode: EditorHistorySaveMode, _ source: UUID?) { + lock.lock() + entries.append((mode, source)) + lock.unlock() + } + + var all: [(mode: EditorHistorySaveMode, source: UUID?)] { + lock.lock() + defer { lock.unlock() } + return entries + } +} + +@MainActor +struct EditorHistoryTests { + private func image(width: Int = 400, height: Int = 300) throws -> CGImage { + let context = try #require( + CGContext( + data: nil, width: width, height: height, bitsPerComponent: 8, + bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.setFillColor(NSColor.white.cgColor) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + return try #require(context.makeImage()) + } + + @Test func saveToHistoryReportsModeAndSource() async throws { + let sourceID = UUID() + let requests = HistoryRequestRecorder() + let model = EditorViewModel( + image: try image(), + context: EditorCaptureContext( + captureMode: "area", supportsVerticalTrim: false, + startsInVerticalTrim: false, sourceEntryID: sourceID + ), + historySaver: { image, mode, source in + #expect(image.width == 400) + requests.append(mode, source) + } + ) + await model.saveToHistory(mode: .overwriteOriginal) + let first = requests.all + #expect(first.count == 1) + #expect(first[0].mode == .overwriteOriginal) + #expect(first[0].source == sourceID) + + await model.saveToHistory(mode: .newRecord) + let all = requests.all + #expect(all.count == 2) + #expect(all[1].mode == .newRecord) + #expect(all[1].source == sourceID) + } + + @Test func overwriteWithoutSourceIsRejected() async throws { + let requests = HistoryRequestRecorder() + let model = EditorViewModel( + image: try image(), + historySaver: { _, mode, source in requests.append(mode, source) } + ) + await model.saveToHistory(mode: .overwriteOriginal) + #expect(requests.all.isEmpty) + #expect(model.toastMessage?.type == .error) + } + + @Test func failedSaveSurfacesErrorToast() async throws { + let model = EditorViewModel( + image: try image(), + historySaver: { _, _, _ in throw HistoryError.entryNotFound(id: UUID()) } + ) + await model.saveToHistory(mode: .newRecord) + #expect(model.toastMessage?.type == .error) + } +} diff --git a/App/SnapGlass/Resources/en.lproj/Localizable.strings b/App/SnapGlass/Resources/en.lproj/Localizable.strings index 733b89c..0128db3 100644 --- a/App/SnapGlass/Resources/en.lproj/Localizable.strings +++ b/App/SnapGlass/Resources/en.lproj/Localizable.strings @@ -40,6 +40,10 @@ "Selection style" = "Selection style"; "Rectangle" = "Rectangle"; "Freeform" = "Freeform"; +"Overlay preview" = "Overlay preview"; +"Live screen" = "Live screen"; +"Static snapshot" = "Static snapshot"; +"Static snapshot freezes the screen when selection starts; unavailable screens appear black." = "Static snapshot freezes the screen when selection starts; unavailable screens appear black."; "Saved image format" = "Saved image format"; "PNG (lossless)" = "PNG (lossless)"; "JPEG (smaller)" = "JPEG (smaller)"; @@ -371,3 +375,44 @@ "Record picked colors" = "Record picked colors"; "Maximum color entries" = "Maximum color entries"; "Colors are stored encrypted on this Mac only. Copying a color with the picker records it here." = "Colors are stored encrypted on this Mac only. Copying a color with the picker records it here."; + +/* Settings gaps */ +"Developer Mode" = "Developer Mode"; +"Diagnostics" = "Diagnostics"; +"Disable languages you rarely use to prevent visually similar characters (such as Japanese kanji and Chinese hanzi) from being misrecognized." = "Disable languages you rarely use to prevent visually similar characters (such as Japanese kanji and Chinese hanzi) from being misrecognized."; +"History segment" = "History segment"; +"Record colors copied with the area or editor color picker" = "Record colors copied with the area or editor color picker"; +"History unavailable" = "History unavailable"; +"No captures yet" = "No captures yet"; + +/* Editor history */ +"Save to History" = "Save to History"; +"Save as New Record" = "Save as New Record"; +"Overwrite Original Record" = "Overwrite Original Record"; +"Save the annotated image into history" = "Save the annotated image into history"; +"Saved to history" = "Saved to history"; +"History updated; original kept for restore" = "History updated; original kept for restore"; +"No original record to overwrite; save as a new record instead" = "No original record to overwrite; save as a new record instead"; +"Restore Original Image" = "Restore Original Image"; +"Original image restored" = "Original image restored"; +"Copy Line" = "Copy Line"; +"Add as Text Annotation" = "Add as Text Annotation"; + +/* Toasts */ +"No text found" = "No text found"; +"Text copied to clipboard" = "Text copied to clipboard"; +"OCR completed" = "OCR completed"; +"Copied OCR text" = "Copied OCR text"; +"Copied to clipboard" = "Copied to clipboard"; +"No image found in clipboard" = "No image found in clipboard"; +"Scrolling capture cancelled" = "Scrolling capture cancelled"; +"Scroll the window, then capture the next frame" = "Scroll the window, then capture the next frame"; +"No visual change detected; scroll and try again" = "No visual change detected; scroll and try again"; +"Frame %d captured" = "Frame %d captured"; +"Saved to %@" = "Saved to %@"; +"Capture failed: %@" = "Capture failed: %@"; +"OCR failed: %@" = "OCR failed: %@"; +"History save failed: %@" = "History save failed: %@"; +"Scroll frame failed: %@" = "Scroll frame failed: %@"; +"Save failed: %@" = "Save failed: %@"; +"Copy failed: %@" = "Copy failed: %@"; diff --git a/App/SnapGlass/Resources/ja.lproj/Localizable.strings b/App/SnapGlass/Resources/ja.lproj/Localizable.strings index d5565ed..e57fcac 100644 --- a/App/SnapGlass/Resources/ja.lproj/Localizable.strings +++ b/App/SnapGlass/Resources/ja.lproj/Localizable.strings @@ -40,6 +40,10 @@ "Selection style" = "選択スタイル"; "Rectangle" = "矩形"; "Freeform" = "自由選択"; +"Overlay preview" = "オーバーレイプレビュー"; +"Live screen" = "ライブ画面"; +"Static snapshot" = "静止スナップショット"; +"Static snapshot freezes the screen when selection starts; unavailable screens appear black." = "静止スナップショットは選択開始時の画面を固定し、取得できない画面は黒く表示します。"; "Saved image format" = "保存形式"; "PNG (lossless)" = "PNG(ロスレス)"; "JPEG (smaller)" = "JPEG(より小さい)"; @@ -371,3 +375,44 @@ "Record picked colors" = "ピックしたカラーを記録"; "Maximum color entries" = "カラー履歴の最大件数"; "Colors are stored encrypted on this Mac only. Copying a color with the picker records it here." = "カラーはこの Mac にのみ暗号化して保存されます。ピッカーでコピーするとここに記録されます。"; + +/* Settings gaps */ +"Developer Mode" = "開発者モード"; +"Diagnostics" = "診断"; +"Disable languages you rarely use to prevent visually similar characters (such as Japanese kanji and Chinese hanzi) from being misrecognized." = "使用頻度の低い言語を無効にすると、字形が似た文字(日本語の漢字や中国語の漢字など)の誤認識を防げます。"; +"History segment" = "履歴セグメント"; +"Record colors copied with the area or editor color picker" = "領域撮影またはエディタのカラーピッカーでコピーしたカラーを記録"; +"History unavailable" = "履歴を利用できません"; +"No captures yet" = "まだ撮影がありません"; + +/* Editor history */ +"Save to History" = "履歴に保存"; +"Save as New Record" = "新規レコードとして保存"; +"Overwrite Original Record" = "元のレコードを上書き"; +"Save the annotated image into history" = "注釈付き画像を履歴に保存"; +"Saved to history" = "履歴に保存しました"; +"History updated; original kept for restore" = "履歴を更新しました。元の画像は復元用に保持されます"; +"No original record to overwrite; save as a new record instead" = "上書きできる元のレコードがないため、新規レコードとして保存します"; +"Restore Original Image" = "元の画像を復元"; +"Original image restored" = "元の画像を復元しました"; +"Copy Line" = "行をコピー"; +"Add as Text Annotation" = "テキスト注釈として追加"; + +/* Toasts */ +"No text found" = "テキストが検出されませんでした"; +"Text copied to clipboard" = "テキストをクリップボードにコピーしました"; +"OCR completed" = "文字認識が完了しました"; +"Copied OCR text" = "認識したテキストをコピーしました"; +"Copied to clipboard" = "クリップボードにコピーしました"; +"No image found in clipboard" = "クリップボードに画像が見つかりません"; +"Scrolling capture cancelled" = "スクロール撮影をキャンセルしました"; +"Scroll the window, then capture the next frame" = "ウィンドウをスクロールして、次のフレームを撮影してください"; +"No visual change detected; scroll and try again" = "画面に変化がありません。スクロールして再試行してください"; +"Frame %d captured" = "%d フレーム目を撮影しました"; +"Saved to %@" = "%@ に保存しました"; +"Capture failed: %@" = "撮影に失敗しました:%@"; +"OCR failed: %@" = "文字認識に失敗しました:%@"; +"History save failed: %@" = "履歴の保存に失敗しました:%@"; +"Scroll frame failed: %@" = "スクロールフレームの撮影に失敗しました:%@"; +"Save failed: %@" = "保存に失敗しました:%@"; +"Copy failed: %@" = "コピーに失敗しました:%@"; diff --git a/App/SnapGlass/Resources/ko.lproj/Localizable.strings b/App/SnapGlass/Resources/ko.lproj/Localizable.strings index 39c1d0d..d47a8e5 100644 --- a/App/SnapGlass/Resources/ko.lproj/Localizable.strings +++ b/App/SnapGlass/Resources/ko.lproj/Localizable.strings @@ -40,6 +40,10 @@ "Selection style" = "선택 스타일"; "Rectangle" = "사각형"; "Freeform" = "자유 선택"; +"Overlay preview" = "오버레이 미리보기"; +"Live screen" = "실시간 화면"; +"Static snapshot" = "정적 스냅샷"; +"Static snapshot freezes the screen when selection starts; unavailable screens appear black." = "정적 스냅샷은 선택을 시작할 때 화면을 고정하며, 캡처할 수 없는 화면은 검게 표시됩니다."; "Saved image format" = "저장 형식"; "PNG (lossless)" = "PNG(무손실)"; "JPEG (smaller)" = "JPEG(더 작은 용량)"; @@ -371,3 +375,44 @@ "Record picked colors" = "복사한 색상 기록"; "Maximum color entries" = "최대 색상 항목 수"; "Colors are stored encrypted on this Mac only. Copying a color with the picker records it here." = "색상은 이 Mac에만 암호화되어 저장됩니다. 피커로 색상을 복사하면 여기에 기록됩니다."; + +/* Settings gaps */ +"Developer Mode" = "개발자 모드"; +"Diagnostics" = "진단"; +"Disable languages you rarely use to prevent visually similar characters (such as Japanese kanji and Chinese hanzi) from being misrecognized." = "자주 사용하지 않는 언어를 비활성화하면 글자가 비슷한 문자(일본어 한자, 중국어 한자 등)가 잘못 인식되는 것을 방지할 수 있습니다."; +"History segment" = "기록 구분"; +"Record colors copied with the area or editor color picker" = "영역 캡처 또는 편집기 색상 선택기로 복사한 색상 기록"; +"History unavailable" = "기록을 사용할 수 없습니다"; +"No captures yet" = "아직 캡처가 없습니다"; + +/* Editor history */ +"Save to History" = "기록에 저장"; +"Save as New Record" = "새 항목으로 저장"; +"Overwrite Original Record" = "원본 항목 덮어쓰기"; +"Save the annotated image into history" = "주석이 달린 이미지를 기록에 저장"; +"Saved to history" = "기록에 저장했습니다"; +"History updated; original kept for restore" = "기록을 업데이트했습니다. 원본은 복원용으로 보관됩니다"; +"No original record to overwrite; save as a new record instead" = "덮어쓸 원본 항목이 없어 새 항목으로 저장합니다"; +"Restore Original Image" = "원본 이미지 복원"; +"Original image restored" = "원본 이미지를 복원했습니다"; +"Copy Line" = "줄 복사"; +"Add as Text Annotation" = "텍스트 주석으로 추가"; + +/* Toasts */ +"No text found" = "텍스트를 찾을 수 없습니다"; +"Text copied to clipboard" = "텍스트를 클립보드에 복사했습니다"; +"OCR completed" = "텍스트 인식 완료"; +"Copied OCR text" = "인식된 텍스트를 복사했습니다"; +"Copied to clipboard" = "클립보드에 복사했습니다"; +"No image found in clipboard" = "클립보드에서 이미지를 찾을 수 없습니다"; +"Scrolling capture cancelled" = "스크롤 캡처를 취소했습니다"; +"Scroll the window, then capture the next frame" = "창을 스크롤한 다음 다음 프레임을 캡처하세요"; +"No visual change detected; scroll and try again" = "화면 변화가 감지되지 않았습니다. 스크롤 후 다시 시도하세요"; +"Frame %d captured" = "%d번째 프레임을 캡처했습니다"; +"Saved to %@" = "%@에 저장했습니다"; +"Capture failed: %@" = "캡처 실패: %@"; +"OCR failed: %@" = "텍스트 인식 실패: %@"; +"History save failed: %@" = "기록 저장 실패: %@"; +"Scroll frame failed: %@" = "스크롤 프레임 캡처 실패: %@"; +"Save failed: %@" = "저장 실패: %@"; +"Copy failed: %@" = "복사 실패: %@"; diff --git a/App/SnapGlass/Resources/zh-Hans.lproj/Localizable.strings b/App/SnapGlass/Resources/zh-Hans.lproj/Localizable.strings index 434ccb5..afe4b12 100644 --- a/App/SnapGlass/Resources/zh-Hans.lproj/Localizable.strings +++ b/App/SnapGlass/Resources/zh-Hans.lproj/Localizable.strings @@ -40,6 +40,10 @@ "Selection style" = "选区样式"; "Rectangle" = "矩形"; "Freeform" = "自由选区"; +"Overlay preview" = "覆盖层预览"; +"Live screen" = "实时画面"; +"Static snapshot" = "静态快照"; +"Static snapshot freezes the screen when selection starts; unavailable screens appear black." = "静态快照会在开始选择时冻结画面;无法捕获的屏幕将显示为黑色。"; "Saved image format" = "保存格式"; "PNG (lossless)" = "PNG(无损)"; "JPEG (smaller)" = "JPEG(体积更小)"; @@ -371,3 +375,44 @@ "Record picked colors" = "记录取色的颜色"; "Maximum color entries" = "颜色历史上限"; "Colors are stored encrypted on this Mac only. Copying a color with the picker records it here." = "颜色仅加密存储在本机。使用取色器复制颜色时会记录到此处。"; + +/* Settings gaps */ +"Developer Mode" = "开发者模式"; +"Diagnostics" = "诊断"; +"Disable languages you rarely use to prevent visually similar characters (such as Japanese kanji and Chinese hanzi) from being misrecognized." = "关闭不常用的语言,可避免字形相近的文字(例如日文汉字与中文汉字)被误识别。"; +"History segment" = "历史记录分段"; +"Record colors copied with the area or editor color picker" = "记录使用区域截图或编辑器取色器复制的颜色"; +"History unavailable" = "历史记录不可用"; +"No captures yet" = "暂无截图"; + +/* Editor history */ +"Save to History" = "存入历史"; +"Save as New Record" = "存为新记录"; +"Overwrite Original Record" = "覆盖原记录"; +"Save the annotated image into history" = "将标注后的图片存入历史记录"; +"Saved to history" = "已存入历史记录"; +"History updated; original kept for restore" = "历史记录已更新,原图已保留以便还原"; +"No original record to overwrite; save as a new record instead" = "没有可覆盖的原记录,将改为新建记录"; +"Restore Original Image" = "还原原图"; +"Original image restored" = "已还原原图"; +"Copy Line" = "复制整行"; +"Add as Text Annotation" = "添加为文字标注"; + +/* Toasts */ +"No text found" = "未检测到文字"; +"Text copied to clipboard" = "文字已复制到剪贴板"; +"OCR completed" = "文字识别完成"; +"Copied OCR text" = "已复制识别文字"; +"Copied to clipboard" = "已复制到剪贴板"; +"No image found in clipboard" = "剪贴板中未找到图片"; +"Scrolling capture cancelled" = "已取消滚动截图"; +"Scroll the window, then capture the next frame" = "请滚动窗口,然后截取下一帧"; +"No visual change detected; scroll and try again" = "画面没有变化,请滚动后重试"; +"Frame %d captured" = "已截取第 %d 帧"; +"Saved to %@" = "已保存至 %@"; +"Capture failed: %@" = "截图失败:%@"; +"OCR failed: %@" = "文字识别失败:%@"; +"History save failed: %@" = "历史记录保存失败:%@"; +"Scroll frame failed: %@" = "滚动帧截取失败:%@"; +"Save failed: %@" = "保存失败:%@"; +"Copy failed: %@" = "复制失败:%@"; diff --git a/App/SnapGlass/Sources/App.swift b/App/SnapGlass/Sources/App.swift index 409bdbd..2ff621b 100644 --- a/App/SnapGlass/Sources/App.swift +++ b/App/SnapGlass/Sources/App.swift @@ -41,6 +41,7 @@ struct SnapGlassApp: App { Window("Preferences", id: "preferences") { PreferencesView() + .navigationTitle(Text("Preferences")) .environmentObject(router) .toast(message: $viewModel.toastMessage, edge: .bottom) .environmentObject(viewModel) @@ -52,13 +53,13 @@ struct SnapGlassApp: App { .windowResizability(.contentMinSize) .commands { CommandGroup(replacing: .appInfo) { - Button("About SnapGlass") { + Button(AppLocalization.string("About SnapGlass")) { router.presentAbout() } } CommandGroup(replacing: .appSettings) { - Button("Preferences...") { + Button(AppLocalization.string("Preferences...")) { router.presentSettings() } .keyboardShortcut(",", modifiers: .command) @@ -67,6 +68,7 @@ struct SnapGlassApp: App { Window("History", id: "history") { HistoryView() + .navigationTitle(Text("History")) .environmentObject(viewModel) .toast(message: $viewModel.toastMessage, edge: .bottom) .environment(\.locale, locale) @@ -85,11 +87,13 @@ struct SnapGlassApp: App { .preferredColorScheme(preferredColorScheme) } } + .navigationTitle(Text("Annotation Editor")) .background(AppWindowRegistrationView(id: "editor")) } Window("Permission Required", id: "permission") { PermissionGuideView() + .navigationTitle(Text("Permission Required")) .toast(message: $viewModel.toastMessage, edge: .bottom) .environment(\.locale, locale) .preferredColorScheme(preferredColorScheme) diff --git a/App/SnapGlass/Sources/AppLanguage.swift b/App/SnapGlass/Sources/AppLanguage.swift index c51ed6d..45d580b 100644 --- a/App/SnapGlass/Sources/AppLanguage.swift +++ b/App/SnapGlass/Sources/AppLanguage.swift @@ -23,4 +23,15 @@ enum AppLanguage: String, CaseIterable, Identifiable { Locale(identifier: "ko") } } + + /// The `.lproj` identifier, or `nil` when the OS should choose. + var resourceIdentifier: String? { + switch self { + case .system: nil + case .english: "en" + case .simplifiedChinese: "zh-Hans" + case .japanese: "ja" + case .korean: "ko" + } + } } diff --git a/App/SnapGlass/Sources/AppLocalization.swift b/App/SnapGlass/Sources/AppLocalization.swift new file mode 100644 index 0000000..dc4cbf9 --- /dev/null +++ b/App/SnapGlass/Sources/AppLocalization.swift @@ -0,0 +1,27 @@ +import Foundation +import SharedKit + +/// Resolves UI strings against the language chosen in Settings rather than the +/// system language that `NSLocalizedString` / `String(localized:)` follow. +/// SwiftUI views already track the injected `\.locale`; this serves the +/// Foundation paths that cannot: toasts, alerts, panels, and AppKit menus. +enum AppLocalization { + private static func bundle() -> Bundle { + let language = UserDefaults.standard.string(forKey: PreferenceKeys.appLanguage) + ?? PreferenceDefaults.appLanguage + guard let identifier = AppLanguage(rawValue: language)?.resourceIdentifier, + let path = Bundle.main.path(forResource: identifier, ofType: "lproj"), + let languageBundle = Bundle(path: path) else { + return .main + } + return languageBundle + } + + static func string(_ key: String) -> String { + bundle().localizedString(forKey: key, value: nil, table: nil) + } + + static func string(_ key: String, _ arguments: CVarArg...) -> String { + String(format: bundle().localizedString(forKey: key, value: nil, table: nil), arguments: arguments) + } +} diff --git a/App/SnapGlass/Sources/AppWindowPresenter.swift b/App/SnapGlass/Sources/AppWindowPresenter.swift index 3da295b..45616cf 100644 --- a/App/SnapGlass/Sources/AppWindowPresenter.swift +++ b/App/SnapGlass/Sources/AppWindowPresenter.swift @@ -23,6 +23,7 @@ protocol ApplicationActivationControlling: AnyObject { func setActivationPolicy(_ policy: NSApplication.ActivationPolicy) -> Bool func requestActivation() + func deactivate() } @MainActor @@ -54,6 +55,10 @@ final class SystemApplicationActivationController: ApplicationActivationControll NSRunningApplication.current.activate(options: [.activateIgnoringOtherApps]) } } + + func deactivate() { + NSApplication.shared.deactivate() + } } @MainActor @@ -75,6 +80,8 @@ final class WindowPresentationCoordinator { private struct WindowObservers { let didBecomeKey: NSObjectProtocol let willClose: NSObjectProtocol + let didMiniaturize: NSObjectProtocol + let didDeminiaturize: NSObjectProtocol } private let activationController: ApplicationActivationControlling @@ -88,6 +95,7 @@ final class WindowPresentationCoordinator { private var windows: [String: WindowReference] = [:] private var observedWindows: [ObjectIdentifier: WindowObservers] = [:] private var timeoutTasks: [String: Task] = [:] + private var reevaluateTask: Task? init( activationController: ApplicationActivationControlling, @@ -218,9 +226,31 @@ final class WindowPresentationCoordinator { } } + let didMiniaturize = NotificationCenter.default.addObserver( + forName: NSWindow.didMiniaturizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.scheduleReevaluate() + } + } + + let didDeminiaturize = NotificationCenter.default.addObserver( + forName: NSWindow.didDeminiaturizeNotification, + object: window, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.scheduleReevaluate() + } + } + observedWindows[token] = WindowObservers( didBecomeKey: didBecomeKey, - willClose: willClose + willClose: willClose, + didMiniaturize: didMiniaturize, + didDeminiaturize: didDeminiaturize ) } @@ -231,6 +261,8 @@ final class WindowPresentationCoordinator { } lifecycleState.completePresentation(id: id) cancelTimeout(id: id) + // 窗口成为 key 即用户可见,补齐晋升到 regular 的对称路径。 + _ = ensureRegularPolicy() logger.info("window.key id=\(id) \(lifecycleSummary)") } @@ -247,10 +279,19 @@ final class WindowPresentationCoordinator { logger.debug("window.close.ignored id=\(id) reason=stale") } - Task { @MainActor [weak self] in + scheduleReevaluate() + } + + /// 合并同一 runloop tick 内的多次重算请求:`willClose` 触发时窗口仍 `isVisible`, + /// 让出一个 tick 等待 `orderOut` 完成后只重算一次。 + private func scheduleReevaluate() { + guard reevaluateTask == nil else { return } + reevaluateTask = Task { @MainActor [weak self] in await Task.yield() - self?.purgeStaleWindows() - self?.reevaluateActivationPolicy() + guard let self else { return } + self.reevaluateTask = nil + self.purgeStaleWindows() + self.reevaluateActivationPolicy() } } @@ -307,6 +348,8 @@ final class WindowPresentationCoordinator { guard let observers = observedWindows.removeValue(forKey: token) else { return } NotificationCenter.default.removeObserver(observers.didBecomeKey) NotificationCenter.default.removeObserver(observers.willClose) + NotificationCenter.default.removeObserver(observers.didMiniaturize) + NotificationCenter.default.removeObserver(observers.didDeminiaturize) } private func reevaluateActivationPolicy() { @@ -318,13 +361,15 @@ final class WindowPresentationCoordinator { logger.info("policy.accessory.defer reason=visible-window \(lifecycleSummary)") return } - if activationController.activationPolicy != .accessory { - let result = activationController.setActivationPolicy(.accessory) - if result { - logger.info("policy.accessory result=true") - } else { - logger.warning("policy.accessory result=false") - } + guard activationController.activationPolicy != .accessory else { return } + let result = activationController.setActivationPolicy(.accessory) + if result { + // 应用仍处于 active 时切换 .accessory 常不能立即移除 Dock 图标, + // 需显式 deactivate 才会生效。 + activationController.deactivate() + logger.info("policy.accessory result=true") + } else { + logger.warning("policy.accessory result=false") } } diff --git a/App/SnapGlass/Sources/Editor/CanvasTextEditor.swift b/App/SnapGlass/Sources/Editor/CanvasTextEditor.swift index 10d4a8e..089c70f 100644 --- a/App/SnapGlass/Sources/Editor/CanvasTextEditor.swift +++ b/App/SnapGlass/Sources/Editor/CanvasTextEditor.swift @@ -99,7 +99,11 @@ extension EditableAnnotationCanvasNSView { endTextEntry() return } - if canvasTextEntryID == id { return } + if canvasTextEntryID == id, let editor = canvasTextEditor { + applyTextStyle(node, to: editor) + layoutTextEntry() + return + } endTextEntry() canvasTextEntryID = id let editor = makeTextEditor(for: node) @@ -126,7 +130,6 @@ extension EditableAnnotationCanvasNSView { private func makeTextEditor(for node: AnnotationNode) -> CanvasTextEditor { let editor = CanvasTextEditor(frame: .zero) - editor.sourceNode = node editor.isRichText = false editor.importsGraphics = false editor.allowsUndo = true @@ -135,10 +138,24 @@ extension EditableAnnotationCanvasNSView { editor.textContainer?.widthTracksTextView = false editor.textContainer?.heightTracksTextView = false editor.textContainer?.lineFragmentPadding = 0 - editor.textContainerInset = CGSize(width: 4 / node.textHorizontalScale, height: 4) - editor.font = NSFont(name: node.fontName, size: node.fontSize) ?? NSFont.systemFont(ofSize: node.fontSize) - editor.textColor = NSColor(cgColor: node.color ?? NSColor.red.cgColor)?.withAlphaComponent(node.opacity) editor.insertionPointColor = .controlAccentColor + editor.string = node.text ?? "" + applyTextStyle(node, to: editor) + editor.setAccessibilityLabel(String(localized: "Edit Text")) + editor.wantsLayer = true + editor.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.65).cgColor + editor.layer?.borderWidth = 1 + return editor + } + + private func applyTextStyle(_ node: AnnotationNode, to editor: CanvasTextEditor) { + let selection = editor.selectedRange() + let horizontalScale = max(node.textHorizontalScale, 0.1) + editor.sourceNode = node + editor.textContainerInset = CGSize(width: 4 / horizontalScale, height: 4) + editor.font = + NSFont(name: node.fontName, size: node.fontSize) ?? NSFont.systemFont(ofSize: node.fontSize) + editor.textColor = NSColor(cgColor: node.color ?? NSColor.red.cgColor)?.withAlphaComponent(node.opacity) editor.drawsBackground = node.fillColor != nil editor.backgroundColor = NSColor(cgColor: node.fillColor ?? NSColor.clear.cgColor) ?? .clear editor.alignment = @@ -147,12 +164,10 @@ extension EditableAnnotationCanvasNSView { case .center: .center case .trailing: .right } - editor.string = node.text ?? "" - editor.setAccessibilityLabel(String(localized: "Edit Text")) - editor.wantsLayer = true - editor.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.65).cgColor - editor.layer?.borderWidth = 1 - return editor + let textLength = editor.string.utf16.count + let location = min(selection.location, textLength) + editor.setSelectedRange( + NSRange(location: location, length: min(selection.length, textLength - location))) } func endTextEntry() { @@ -168,19 +183,34 @@ extension EditableAnnotationCanvasNSView { guard let editor = canvasTextEditor, var node = editor.sourceNode, let image else { return } let display = aspectFitRect(imageSize: CGSize(width: image.width, height: image.height), in: bounds) node.text = editor.string.isEmpty ? " " : editor.string - let measured = TextTool().suggestedSize(for: node) - let width = min(max(measured.width, CGFloat(image.width) * 0.01), CGFloat(image.width)) - let height = min(max(measured.height, CGFloat(image.height) * 0.01), CGFloat(image.height)) let origin = node.normalizedRect == .zero ? (node.points.first ?? .zero) : node.normalizedRect.origin + let imageWidth = CGFloat(image.width) + let imageHeight = CGFloat(image.height) + let maximumWidth = max(imageWidth * (1 - min(max(origin.x, 0), 1)), 1) + let measured = TextTool().suggestedSize(for: node, maximumWidth: maximumWidth) + let width = min(max(measured.width, imageWidth * 0.01), imageWidth) + let horizontalScale = max(node.textHorizontalScale, 0.1) + let contentWidth = max((width - 8) / horizontalScale, 1) + let textKitHeight: CGFloat + if let textContainer = editor.textContainer, let layoutManager = editor.layoutManager { + textContainer.containerSize = CGSize(width: contentWidth, height: max(imageHeight - 8, 1)) + layoutManager.ensureLayout(for: textContainer) + textKitHeight = + ceil(layoutManager.usedRect(for: textContainer).height) + + editor.textContainerInset.height * 2 + 1 + } else { + textKitHeight = 0 + } + let height = min(max(measured.height, textKitHeight, imageHeight * 0.01), imageHeight) let originX = min(max(origin.x, 0), 1 - width / CGFloat(image.width)) let originY = min(max(origin.y, 0), 1 - height / CGFloat(image.height)) editor.frame = CGRect( x: display.minX + originX * display.width, y: display.minY + originY * display.height, width: width / CGFloat(image.width) * display.width, height: height / CGFloat(image.height) * display.height) - editor.bounds = CGRect(x: 0, y: 0, width: width / node.textHorizontalScale, height: height) + editor.bounds = CGRect(x: 0, y: 0, width: width / horizontalScale, height: height) editor.textContainer?.containerSize = CGSize( - width: max((width - 8) / node.textHorizontalScale, 1), height: max(height - 8, 1) + width: contentWidth, height: max(height - editor.textContainerInset.height * 2, 1) ) } } diff --git a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasCrop.swift b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasCrop.swift index 37e2ab2..9fb7a9b 100644 --- a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasCrop.swift +++ b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasCrop.swift @@ -123,14 +123,8 @@ extension EditableAnnotationCanvasNSView { private func drawCropConfirmationHint(for rect: CGRect) { let hint = verticalCropOnly - ? NSLocalizedString( - "Drag the top or bottom edge, then press Return", - comment: "Long screenshot endpoint trim hint" - ) - : NSLocalizedString( - "Return / double-click to crop", - comment: "Crop confirmation hint" - ) + ? AppLocalization.string("Drag the top or bottom edge, then press Return") + : AppLocalization.string("Return / double-click to crop") let attributes: [NSAttributedString.Key: Any] = [ .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .medium), .foregroundColor: NSColor.white, diff --git a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+OCRInteraction.swift b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+OCRInteraction.swift index 54e2a30..d2f2557 100644 --- a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+OCRInteraction.swift +++ b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+OCRInteraction.swift @@ -123,13 +123,21 @@ extension EditableAnnotationCanvasNSView { let menu = NSMenu() let copySelection = menu.addItem( - withTitle: "Copy", + withTitle: AppLocalization.string("Copy"), action: #selector(copyContextualOCRSelection), keyEquivalent: "" ) copySelection.isEnabled = ocrTextSelection?.isEmpty == false - menu.addItem(withTitle: "Copy Line", action: #selector(copyContextualOCRLine), keyEquivalent: "") - menu.addItem(withTitle: "Add as Text Annotation", action: #selector(addContextualOCRLine), keyEquivalent: "") + menu.addItem( + withTitle: AppLocalization.string("Copy Line"), + action: #selector(copyContextualOCRLine), + keyEquivalent: "" + ) + menu.addItem( + withTitle: AppLocalization.string("Add as Text Annotation"), + action: #selector(addContextualOCRLine), + keyEquivalent: "" + ) menu.items.forEach { $0.target = self } return menu } diff --git a/App/SnapGlass/Sources/Editor/EditorCaptureContext.swift b/App/SnapGlass/Sources/Editor/EditorCaptureContext.swift index 1fb5410..32eb64a 100644 --- a/App/SnapGlass/Sources/Editor/EditorCaptureContext.swift +++ b/App/SnapGlass/Sources/Editor/EditorCaptureContext.swift @@ -1,10 +1,12 @@ import AnnotationCore import CoreGraphics +import Foundation public struct EditorCaptureContext: Equatable, Sendable { let captureMode: String? let supportsVerticalTrim: Bool let startsInVerticalTrim: Bool + let sourceEntryID: UUID? public static let standard = EditorCaptureContext( captureMode: nil, @@ -12,24 +14,35 @@ public struct EditorCaptureContext: Equatable, Sendable { startsInVerticalTrim: false ) - init(image: CGImage, captureMode: String?) { + public static func capture( + image: CGImage, captureMode: String?, sourceEntryID: UUID? = nil + ) -> EditorCaptureContext { let isScrollingCapture = captureMode == "scroll" - self.captureMode = captureMode - self.supportsVerticalTrim = LongImageEditingPolicy.supportsVerticalTrim( - imageWidth: image.width, - imageHeight: image.height, - isScrollingCapture: isScrollingCapture + return EditorCaptureContext( + captureMode: captureMode, + supportsVerticalTrim: LongImageEditingPolicy.supportsVerticalTrim( + imageWidth: image.width, + imageHeight: image.height, + isScrollingCapture: isScrollingCapture + ), + startsInVerticalTrim: isScrollingCapture, + sourceEntryID: sourceEntryID ) - self.startsInVerticalTrim = isScrollingCapture } - private init( + init(image: CGImage, captureMode: String?) { + self = .capture(image: image, captureMode: captureMode) + } + + init( captureMode: String?, supportsVerticalTrim: Bool, - startsInVerticalTrim: Bool + startsInVerticalTrim: Bool, + sourceEntryID: UUID? = nil ) { self.captureMode = captureMode self.supportsVerticalTrim = supportsVerticalTrim self.startsInVerticalTrim = startsInVerticalTrim + self.sourceEntryID = sourceEntryID } } diff --git a/App/SnapGlass/Sources/Editor/EditorView.swift b/App/SnapGlass/Sources/Editor/EditorView.swift index e9eae00..b50358a 100644 --- a/App/SnapGlass/Sources/Editor/EditorView.swift +++ b/App/SnapGlass/Sources/Editor/EditorView.swift @@ -155,6 +155,22 @@ struct EditorView: View { .toggleStyle(.button) .help("Show or hide recognized text regions") + Menu { + Button("Save as New Record") { + Task { await editorVM.saveToHistory(mode: .newRecord) } + } + if editorVM.canOverwriteOriginal { + Button("Overwrite Original Record") { + Task { await editorVM.saveToHistory(mode: .overwriteOriginal) } + } + } + } label: { + Label("Save to History", systemImage: "square.and.arrow.down.on.square") + } + .keyboardShortcut("h", modifiers: [.command]) + .help("Save the annotated image into history") + .disabled(editorVM.isEnteringText) + Button("Copy") { editorVM.copyToClipboard() } diff --git a/App/SnapGlass/Sources/Editor/EditorViewModel+OCR.swift b/App/SnapGlass/Sources/Editor/EditorViewModel+OCR.swift index b7f0510..bedd705 100644 --- a/App/SnapGlass/Sources/Editor/EditorViewModel+OCR.swift +++ b/App/SnapGlass/Sources/Editor/EditorViewModel+OCR.swift @@ -1,5 +1,5 @@ -import AppKit import AnnotationCore +import AppKit import BarcodeCore import HistoryCore import OCRCore @@ -9,258 +9,257 @@ import SwiftUI // MARK: - EditorViewModel OCR / Color Picker / Barcode extension EditorViewModel { - public func startOCR() { - ocrTask?.cancel() - ocrGeneration &+= 1 - let generation = ocrGeneration - guard let image = document?.baseImage else { - isOCRRunning = false - return - } - isOCRRunning = true - ocrTask = Task { [weak self] in - guard let self else { return } - do { - let options = OCROptions( - languages: ["zh-Hans", "en-US"], - minConfidence: 0.1, - preserveLayout: true - ) - let result = try await recognizeImage(image, options) - guard !Task.isCancelled, generation == ocrGeneration else { return } - ocrLines = result.observations - showsOCROverlay = true - isOCRRunning = false - logger.info("Editor OCR completed with \(result.observations.count) lines") - } catch is CancellationError { - guard generation == ocrGeneration else { return } - isOCRRunning = false - } catch { - guard !Task.isCancelled, generation == ocrGeneration else { return } - ocrLines = [] - isOCRRunning = false - showToast(message: "OCR failed: \(error.localizedDescription)", type: .error) - } - } + public func startOCR() { + ocrTask?.cancel() + ocrGeneration &+= 1 + let generation = ocrGeneration + guard let image = document?.baseImage else { + isOCRRunning = false + return } - - func restartOCRForCurrentImage() { - cancelBarcodeScan() + isOCRRunning = true + ocrTask = Task { [weak self] in + guard let self else { return } + do { + let options = OCROptions( + languages: ["zh-Hans", "en-US"], + minConfidence: 0.1, + preserveLayout: true + ) + let result = try await recognizeImage(image, options) + guard !Task.isCancelled, generation == ocrGeneration else { return } + ocrLines = result.observations + showsOCROverlay = true + isOCRRunning = false + logger.info("Editor OCR completed with \(result.observations.count) lines") + } catch is CancellationError { + guard generation == ocrGeneration else { return } + isOCRRunning = false + } catch { + guard !Task.isCancelled, generation == ocrGeneration else { return } ocrLines = [] - startOCR() + isOCRRunning = false + showToast(message: AppLocalization.string("OCR failed: %@", error.localizedDescription), type: .error) + } } + } - public func copyOCRLine(_ line: OCRLine) { - copyOCRText(line.text) - } + func restartOCRForCurrentImage() { + cancelBarcodeScan() + ocrLines = [] + startOCR() + } - public func copyOCRLines(_ lines: [OCRLine]) { - copyOCRText(lines.map(\.text).joined(separator: "\n")) - } + public func copyOCRLine(_ line: OCRLine) { + copyOCRText(line.text) + } - public func copyOCRSelection(_ text: String) { - copyOCRText(text) - } + public func copyOCRLines(_ lines: [OCRLine]) { + copyOCRText(lines.map(\.text).joined(separator: "\n")) + } - public func copyAllOCRText() { - copyOCRLines(ocrLines) - } + public func copyOCRSelection(_ text: String) { + copyOCRText(text) + } - public func addOCRLineAsAnnotation(_ line: OCRLine) { - let rect = line.editorBoundingBox - let node = AnnotationNode( - tool: .text, - color: cgColor, - lineWidth: strokeWidth, - opacity: annotationOpacity, - fillColor: fillEnabled ? NSColor(fillColor).cgColor : nil, - points: [rect.origin], - text: line.text, - fontName: fontName, - fontSize: max(fontSize, rect.height * CGFloat(document?.baseImage.height ?? 1) * 0.8), - textAlignment: textAlignment, - normalizedRect: rect - ) - addNode(fittedTextNode(node)) - selectedTool = .select - } + public func copyAllOCRText() { + copyOCRLines(ocrLines) + } - func fittedTextNode(_ source: AnnotationNode) -> AnnotationNode { - guard source.tool == .text, let image = document?.baseImage else { return source } - var node = source - let origin = node.normalizedRect.origin != .zero - ? node.normalizedRect.origin - : (node.points.first ?? .zero) - let size = TextTool().suggestedSize(for: node) - let normalizedWidth = size.width / CGFloat(max(image.width, 1)) - let normalizedHeight = size.height / CGFloat(max(image.height, 1)) - let fittedWidth = min(max(normalizedWidth, 0.01), 1) - let fittedHeight = min(max(normalizedHeight, 0.01), 1) - node.normalizedRect = CGRect( - x: min(max(origin.x, 0), 1 - fittedWidth), - y: min(max(origin.y, 0), 1 - fittedHeight), - width: fittedWidth, - height: fittedHeight - ) - node.points = [node.normalizedRect.origin] - return node - } + public func addOCRLineAsAnnotation(_ line: OCRLine) { + let rect = line.editorBoundingBox + let node = AnnotationNode( + tool: .text, + color: cgColor, + lineWidth: strokeWidth, + opacity: annotationOpacity, + fillColor: fillEnabled ? NSColor(fillColor).cgColor : nil, + points: [rect.origin], + text: line.text, + fontName: fontName, + fontSize: max(fontSize, rect.height * CGFloat(document?.baseImage.height ?? 1) * 0.8), + textAlignment: textAlignment, + normalizedRect: rect + ) + addNode(fittedTextNode(node)) + selectedTool = .select + } + + func fittedTextNode(_ source: AnnotationNode) -> AnnotationNode { + guard source.tool == .text, let image = document?.baseImage else { return source } + var node = source + let origin = + node.normalizedRect.origin != .zero + ? node.normalizedRect.origin + : (node.points.first ?? .zero) + let imageWidth = CGFloat(max(image.width, 1)) + let maximumWidth = max(imageWidth * (1 - min(max(origin.x, 0), 1)), 1) + let size = TextTool().suggestedSize(for: node, maximumWidth: maximumWidth) + let normalizedWidth = size.width / CGFloat(max(image.width, 1)) + let normalizedHeight = size.height / CGFloat(max(image.height, 1)) + let fittedWidth = min(max(normalizedWidth, 0.01), 1) + let fittedHeight = min(max(normalizedHeight, 0.01), 1) + node.normalizedRect = CGRect( + x: min(max(origin.x, 0), 1 - fittedWidth), + y: min(max(origin.y, 0), 1 - fittedHeight), + width: fittedWidth, + height: fittedHeight + ) + node.points = [node.normalizedRect.origin] + return node + } + + func applyingCurrentStyle(to source: AnnotationNode) -> AnnotationNode { + var node = source + node.color = cgColor + node.lineWidth = strokeWidth + node.opacity = annotationOpacity + node.fillColor = fillEnabled ? NSColor(fillColor).cgColor : nil + node.strokeStyle = strokeStyle + node.cornerRadius = cornerRadius + node.arrowStyle = arrowStyle + node.fontName = fontName + node.fontSize = fontSize + node.textAlignment = textAlignment + node.blurMode = blurMode + node.blurIntensity = blurIntensity + return node + } + + private func copyOCRText(_ text: String) { + guard !text.isEmpty else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + showToast(message: AppLocalization.string("Copied OCR text"), type: .success) + } - private func copyOCRText(_ text: String) { - guard !text.isEmpty else { return } - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - showToast(message: "Copied OCR text", type: .success) + /// Handles a single-point color pick from the canvas. + public func handleColorPicked(_ color: SampledColor) { + pickerHoverColor = color + copyColorToClipboard(color) + } + + /// Handles a region pick, computing the average and dominant colors. + /// Only the dominant (first) color reaches the clipboard/history so the + /// history is not flooded by one region pick. + public func handleRegionColorsPicked(_ colors: [SampledColor]) { + guard let dominant = colors.first else { return } + pickerDominantColors = colors + pickerAverageColor = averageColor(of: colors) + copyColorToClipboard(dominant) + } + + private func averageColor(of colors: [SampledColor]) -> SampledColor? { + guard !colors.isEmpty else { return nil } + var totalRed = 0 + var totalGreen = 0 + var totalBlue = 0 + for color in colors { + totalRed += Int(color.red) + totalGreen += Int(color.green) + totalBlue += Int(color.blue) } + let count = colors.count + return SampledColor( + red: UInt8(totalRed / count), + green: UInt8(totalGreen / count), + blue: UInt8(totalBlue / count), + alpha: 255 + ) + } - /// Handles a single-point color pick from the canvas. - public func handleColorPicked(_ color: SampledColor) { - pickerHoverColor = color - copyColorToClipboard(color) + func copyColorToClipboard(_ color: SampledColor) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(color.hexString, forType: .string) + showToast( + message: AppLocalization.string("Color %@ copied", color.hexString), + type: .success + ) + recordColorHistory(color) + } + + /// Single recording point for editor-side color picks. Every copy path + /// (single point, region, inspector swatch) funnels through here, so a + /// pick is recorded exactly once. + private func recordColorHistory(_ color: SampledColor) { + guard colorHistoryEnabled else { return } + guard let colorHistory = ColorHistoryStore.shared else { return } + Task { + try? await colorHistory.save(color, source: .editor) } + } - /// Handles a region pick, computing the average and dominant colors. - /// Only the dominant (first) color reaches the clipboard/history so the - /// history is not flooded by one region pick. - public func handleRegionColorsPicked(_ colors: [SampledColor]) { - guard let dominant = colors.first else { return } - pickerDominantColors = colors - pickerAverageColor = averageColor(of: colors) - copyColorToClipboard(dominant) + private var colorHistoryEnabled: Bool { + guard UserDefaults.standard.object(forKey: PreferenceKeys.colorHistoryEnabled) != nil else { + return PreferenceDefaults.colorHistoryEnabled } + return UserDefaults.standard.bool(forKey: PreferenceKeys.colorHistoryEnabled) + } - private func averageColor(of colors: [SampledColor]) -> SampledColor? { - guard !colors.isEmpty else { return nil } - var totalRed = 0 - var totalGreen = 0 - var totalBlue = 0 - for color in colors { - totalRed += Int(color.red) - totalGreen += Int(color.green) - totalBlue += Int(color.blue) - } - let count = colors.count - return SampledColor( - red: UInt8(totalRed / count), - green: UInt8(totalGreen / count), - blue: UInt8(totalBlue / count), - alpha: 255 - ) + /// Manually scans the current editor image and copies decoded barcode content. + public func scanBarcodes() { + barcodeTask?.cancel() + barcodeGeneration &+= 1 + let generation = barcodeGeneration + guard let image = document?.baseImage else { + isBarcodeScanning = false + return } - func copyColorToClipboard(_ color: SampledColor) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(color.hexString, forType: .string) + isBarcodeScanning = true + barcodeTask = Task { [weak self] in + guard let self else { return } + do { + let results = try await detectBarcodes(image, []) + guard !Task.isCancelled, generation == barcodeGeneration else { return } + isBarcodeScanning = false + handleBarcodeResults(results) + } catch is CancellationError { + guard generation == barcodeGeneration else { return } + isBarcodeScanning = false + } catch { + guard !Task.isCancelled, generation == barcodeGeneration else { return } + isBarcodeScanning = false showToast( - message: String( - format: NSLocalizedString( - "Color %@ copied", - comment: "Editor color picker copy success" - ), - color.hexString - ), - type: .success + message: AppLocalization.string("Barcode scan failed: %@", error.localizedDescription), + type: .error ) - recordColorHistory(color) + } } + } - /// Single recording point for editor-side color picks. Every copy path - /// (single point, region, inspector swatch) funnels through here, so a - /// pick is recorded exactly once. - private func recordColorHistory(_ color: SampledColor) { - guard colorHistoryEnabled else { return } - guard let colorHistory = ColorHistoryStore.shared else { return } - Task { - try? await colorHistory.save(color, source: .editor) - } + /// 将识别到的条码内容复制到剪贴板并提示。 + private func handleBarcodeResults(_ results: [BarcodeResult]) { + let payloads = results.map(\.payload).filter { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } - - private var colorHistoryEnabled: Bool { - guard UserDefaults.standard.object(forKey: PreferenceKeys.colorHistoryEnabled) != nil else { - return PreferenceDefaults.colorHistoryEnabled - } - return UserDefaults.standard.bool(forKey: PreferenceKeys.colorHistoryEnabled) + guard !payloads.isEmpty else { + showToast( + message: AppLocalization.string("No barcode found"), + type: .info + ) + return } - /// Manually scans the current editor image and copies decoded barcode content. - public func scanBarcodes() { - barcodeTask?.cancel() - barcodeGeneration &+= 1 - let generation = barcodeGeneration - guard let image = document?.baseImage else { - isBarcodeScanning = false - return - } - - isBarcodeScanning = true - barcodeTask = Task { [weak self] in - guard let self else { return } - do { - let results = try await detectBarcodes(image, []) - guard !Task.isCancelled, generation == barcodeGeneration else { return } - isBarcodeScanning = false - handleBarcodeResults(results) - } catch is CancellationError { - guard generation == barcodeGeneration else { return } - isBarcodeScanning = false - } catch { - guard !Task.isCancelled, generation == barcodeGeneration else { return } - isBarcodeScanning = false - showToast( - message: String( - format: NSLocalizedString( - "Barcode scan failed: %@", - comment: "Editor barcode scan failure" - ), - error.localizedDescription - ), - type: .error - ) - } - } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(payloads.joined(separator: "\n"), forType: .string) + if payloads.count == 1 { + showToast( + message: AppLocalization.string("Barcode copied to clipboard"), + type: .success + ) + } else { + showToast( + message: AppLocalization.string("%d barcodes copied to clipboard", payloads.count), + type: .success + ) } + logger.info("Editor barcode scan copied \(payloads.count) result(s)") + } - /// 将识别到的条码内容复制到剪贴板并提示。 - private func handleBarcodeResults(_ results: [BarcodeResult]) { - let payloads = results.map(\.payload).filter { - !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - guard !payloads.isEmpty else { - showToast( - message: NSLocalizedString("No barcode found", comment: "Editor barcode scan empty result"), - type: .info - ) - return - } - - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(payloads.joined(separator: "\n"), forType: .string) - if payloads.count == 1 { - showToast( - message: NSLocalizedString( - "Barcode copied to clipboard", - comment: "Editor barcode copy success" - ), - type: .success - ) - } else { - showToast( - message: String( - format: NSLocalizedString( - "%d barcodes copied to clipboard", - comment: "Editor multiple barcode copy success" - ), - payloads.count - ), - type: .success - ) - } - logger.info("Editor barcode scan copied \(payloads.count) result(s)") - } - - func cancelBarcodeScan() { - barcodeTask?.cancel() - barcodeGeneration &+= 1 - isBarcodeScanning = false - } + func cancelBarcodeScan() { + barcodeTask?.cancel() + barcodeGeneration &+= 1 + isBarcodeScanning = false + } } diff --git a/App/SnapGlass/Sources/Editor/EditorViewModel+Save.swift b/App/SnapGlass/Sources/Editor/EditorViewModel+Save.swift index 63778b9..c798b4d 100644 --- a/App/SnapGlass/Sources/Editor/EditorViewModel+Save.swift +++ b/App/SnapGlass/Sources/Editor/EditorViewModel+Save.swift @@ -1,5 +1,6 @@ import AppKit import AnnotationCore +import HistoryCore import SharedKit import SwiftUI import UniformTypeIdentifiers @@ -7,6 +8,50 @@ import UniformTypeIdentifiers // MARK: - EditorViewModel Save / Copy / Cancel / Toast extension EditorViewModel { + static let defaultHistorySaver: + @Sendable (CGImage, EditorHistorySaveMode, UUID?) async throws -> Void = { image, mode, sourceEntryID in + let history = try HistoryActor.sharedResult().get() + switch mode { + case .newRecord: + try await history.saveCapture( + image: image, textContent: "", ocrConfidence: 0, + captureMode: "edited" + ) + case .overwriteOriginal: + guard let sourceEntryID else { + throw HistoryError.entryNotFound(id: UUID()) + } + try await history.replaceImage(id: sourceEntryID, image: image) + } + } + + /// Writes the annotated image to history as a new record or as an + /// overwrite of the originating capture. + public func saveToHistory(mode: EditorHistorySaveMode) async { + guard let doc = document else { return } + if mode == .overwriteOriginal && sourceEntryID == nil { + showToast( + message: AppLocalization.string("No original record to overwrite; save as a new record instead"), + type: .error + ) + return + } + do { + let image = try interactor.render(doc) + try await historySaver(image, mode, sourceEntryID) + let message: String = switch mode { + case .newRecord: AppLocalization.string("Saved to history") + case .overwriteOriginal: AppLocalization.string("History updated; original kept for restore") + } + showToast(message: message, type: .success) + logger.info("Editor image saved to history (mode: \(mode))") + } catch { + showToast( + message: AppLocalization.string("History save failed: %@", error.localizedDescription), + type: .error + ) + } + } /// Saves the annotated image to a user-chosen file location. public func save() { guard let doc = document else { return } @@ -27,10 +72,13 @@ extension EditorViewModel { ? PreferenceDefaults.captureJPEGQuality : UserDefaults.standard.double(forKey: PreferenceKeys.captureJPEGQuality) try ImageEncoder.write(image, to: url, format: format, jpegQuality: quality) - self.showToast(message: "Saved to \(url.lastPathComponent)", type: .success) + self.showToast(message: AppLocalization.string("Saved to %@", url.lastPathComponent), type: .success) self.logger.info("Saved annotated image to \(url.path())") } catch { - self.showToast(message: "Save failed: \(error.localizedDescription)", type: .error) + self.showToast( + message: AppLocalization.string("Save failed: %@", error.localizedDescription), + type: .error + ) } } } @@ -46,10 +94,10 @@ extension EditorViewModel { ) NSPasteboard.general.clearContents() NSPasteboard.general.writeObjects([nsImage]) - showToast(message: "Copied to clipboard", type: .success) + showToast(message: AppLocalization.string("Copied to clipboard"), type: .success) logger.info("Copied annotated image to clipboard") } catch { - showToast(message: "Copy failed: \(error.localizedDescription)", type: .error) + showToast(message: AppLocalization.string("Copy failed: %@", error.localizedDescription), type: .error) } } diff --git a/App/SnapGlass/Sources/Editor/EditorViewModel.swift b/App/SnapGlass/Sources/Editor/EditorViewModel.swift index be900d0..56d8e4e 100644 --- a/App/SnapGlass/Sources/Editor/EditorViewModel.swift +++ b/App/SnapGlass/Sources/Editor/EditorViewModel.swift @@ -1,12 +1,21 @@ -import SwiftUI -import AppKit import AnnotationCore +import AppKit import BarcodeCore import HistoryCore import OCRCore import SharedKit +import SwiftUI import UniformTypeIdentifiers +/// How an edited image should be written back to history. +public enum EditorHistorySaveMode: Sendable, Equatable { + /// Writes a fresh history entry. + case newRecord + /// Replaces the media of the original capture, keeping the entry id and + /// metadata; the original stays recoverable through `restoreOriginal`. + case overwriteOriginal +} + /// View model for the annotation editor window. /// /// Manages the annotation document lifecycle: creating documents from captured images, @@ -14,485 +23,465 @@ import UniformTypeIdentifiers /// and handling save/copy/cancel actions. @MainActor public final class EditorViewModel: ObservableObject { - /// The annotation interactor for applying tools, undo/redo, and rendering. - public let interactor: AnnotationInteractor - - /// The current annotation document, created from the captured image. - @Published public var document: AnnotationDocument? + /// The annotation interactor for applying tools, undo/redo, and rendering. + public let interactor: AnnotationInteractor - /// The currently selected annotation tool. - @Published var selectedTool: EditorTool = .select + /// The current annotation document, created from the captured image. + @Published public var document: AnnotationDocument? - /// Whether the current image supports the specialized long-image trim workflow. - let supportsVerticalTrim: Bool + /// The currently selected annotation tool. + @Published var selectedTool: EditorTool = .select - /// Whether crop interaction is currently constrained to the top and bottom edges. - @Published private(set) var isVerticalTrimEnabled: Bool + /// Whether the current image supports the specialized long-image trim workflow. + let supportsVerticalTrim: Bool - /// Current style preset. Direct control edits switch this to custom. - @Published var selectedPreset: AnnotationStylePreset = .emphasis + /// Whether crop interaction is currently constrained to the top and bottom edges. + @Published private(set) var isVerticalTrimEnabled: Bool - /// The currently selected color for annotations. - @Published public var selectedColor: Color = .red + /// Current style preset. Direct control edits switch this to custom. + @Published var selectedPreset: AnnotationStylePreset = .emphasis - /// The stroke width for line-based tools. - @Published public var strokeWidth: CGFloat = 3.0 + /// The currently selected color for annotations. + @Published public var selectedColor: Color = .red - @Published public var annotationOpacity: CGFloat = 1.0 - @Published public var fillEnabled = false - @Published public var fillColor: Color = .clear - @Published public var strokeStyle: AnnotationStrokeStyle = .solid - @Published public var cornerRadius: CGFloat = 0 - @Published public var arrowStyle: AnnotationArrowStyle = .filled - @Published public var fontName = "Helvetica" - @Published public var fontSize: CGFloat = 24 - @Published public var textAlignment: AnnotationTextAlignment = .leading - @Published public var blurMode: AnnotationBlurMode = .gaussian - @Published public var blurIntensity: CGFloat = 0.5 + /// The stroke width for line-based tools. + @Published public var strokeWidth: CGFloat = 3.0 - /// Currently selected annotation node. - @Published public var selectedNodeID: UUID? + @Published public var annotationOpacity: CGFloat = 1.0 + @Published public var fillEnabled = false + @Published public var fillColor: Color = .clear + @Published public var strokeStyle: AnnotationStrokeStyle = .solid + @Published public var cornerRadius: CGFloat = 0 + @Published public var arrowStyle: AnnotationArrowStyle = .filled + @Published public var fontName = "Helvetica" + @Published public var fontSize: CGFloat = 24 + @Published public var textAlignment: AnnotationTextAlignment = .leading + @Published public var blurMode: AnnotationBlurMode = .gaussian + @Published public var blurIntensity: CGFloat = 0.5 - /// OCR overlay state and recognized lines. - @Published public internal(set) var ocrLines: [OCRLine] = [] - @Published public internal(set) var isOCRRunning = false - @Published public var showsOCROverlay = true + /// Currently selected annotation node. + @Published public var selectedNodeID: UUID? - /// Live color under the picker cursor while hovering. - @Published public internal(set) var pickerHoverColor: SampledColor? + /// OCR overlay state and recognized lines. + @Published public internal(set) var ocrLines: [OCRLine] = [] + @Published public internal(set) var isOCRRunning = false + @Published public var showsOCROverlay = true - /// Average color of the last picked region. - @Published public internal(set) var pickerAverageColor: SampledColor? + /// Live color under the picker cursor while hovering. + @Published public internal(set) var pickerHoverColor: SampledColor? - /// Dominant colors of the last picked region. - @Published public internal(set) var pickerDominantColors: [SampledColor] = [] + /// Average color of the last picked region. + @Published public internal(set) var pickerAverageColor: SampledColor? - /// Whether the editor is manually scanning the current image for barcodes. - @Published public internal(set) var isBarcodeScanning = false + /// Dominant colors of the last picked region. + @Published public internal(set) var pickerDominantColors: [SampledColor] = [] - /// Text currently being entered for a pending text annotation. - @Published public var textDraft = "" + /// Whether the editor is manually scanning the current image for barcodes. + @Published public internal(set) var isBarcodeScanning = false - /// Whether the in-canvas text editor is active. - @Published public var isEnteringText = false - @Published private(set) var textEntryID = UUID() + /// Text currently being entered for a pending text annotation. + @Published public var textDraft = "" - /// Whether undo is available. - public var canUndo: Bool { document?.canUndo == true } + /// Whether the in-canvas text editor is active. + @Published public var isEnteringText = false + @Published private(set) var textEntryID = UUID() - /// Whether redo is available. - public var canRedo: Bool { document?.canRedo == true } + /// Whether undo is available. + public var canUndo: Bool { document?.canUndo == true } - /// The current toast message to display. - @Published public var toastMessage: ToastMessage? - - /// Number of dominant colors reported for region picks (3–6). - public var dominantColorCount: Int { - let stored = UserDefaults.standard.integer(forKey: PreferenceKeys.pickerDominantColorCount) - if UserDefaults.standard.object(forKey: PreferenceKeys.pickerDominantColorCount) == nil { - return PreferenceDefaults.pickerDominantColorCount - } - return min(max(stored, 3), 6) - } - - /// Called when the user cancels editing to close the editor window. - public var onClose: (() -> Void)? - - let logger = Logger(category: "editor") - let recognizeImage: @Sendable (CGImage, OCROptions) async throws -> OCRResult - let detectBarcodes: @Sendable (CGImage, [BarcodeType]) async throws -> [BarcodeResult] - private var pendingTextPoint: CGPoint? - private var editingTextNodeID: UUID? - var ocrTask: Task? - var ocrGeneration = 0 - var barcodeTask: Task? - var barcodeGeneration = 0 - - /// Creates a new editor view model. - /// - /// - Parameter interactor: The annotation interactor to use. - public init( - image: CGImage? = nil, - context: EditorCaptureContext = .standard, - interactor: AnnotationInteractor = AnnotationInteractor(), - ocrPipeline: OCRPipeline = OCRPipeline(), - barcodeEngine: VisionBarcodeEngine = VisionBarcodeEngine() - ) { - self.interactor = interactor - self.supportsVerticalTrim = context.supportsVerticalTrim - self.isVerticalTrimEnabled = context.startsInVerticalTrim - self.recognizeImage = { image, options in - try await ocrPipeline.recognize(image, options: options) - } - self.detectBarcodes = { image, types in - try await barcodeEngine.detect(in: image, types: types) - } - if let image { - self.document = interactor.createDocument(from: image) - if context.startsInVerticalTrim { - self.selectedTool = .crop - } - startOCR() - } - } + /// Whether redo is available. + public var canRedo: Bool { document?.canRedo == true } - init( - image: CGImage? = nil, - context: EditorCaptureContext = .standard, - interactor: AnnotationInteractor = AnnotationInteractor(), - recognizeImage: @escaping @Sendable (CGImage, OCROptions) async throws -> OCRResult, - detectBarcodes: @escaping @Sendable (CGImage, [BarcodeType]) async throws -> [BarcodeResult] = { image, types in - try await VisionBarcodeEngine().detect(in: image, types: types) - } - ) { - self.interactor = interactor - self.supportsVerticalTrim = context.supportsVerticalTrim - self.isVerticalTrimEnabled = context.startsInVerticalTrim - self.recognizeImage = recognizeImage - self.detectBarcodes = detectBarcodes - if let image { - self.document = interactor.createDocument(from: image) - if context.startsInVerticalTrim { - self.selectedTool = .crop - } - startOCR() - } - } + /// Whether a reversible overwrite of the originating history record exists. + public var canOverwriteOriginal: Bool { sourceEntryID != nil } - deinit { - ocrTask?.cancel() - barcodeTask?.cancel() - } + /// The current toast message to display. + @Published public var toastMessage: ToastMessage? - /// Converts the SwiftUI `Color` to a `CGColor` for the `AnnotationNode`. - public var cgColor: CGColor { - NSColor(selectedColor).cgColor + /// Number of dominant colors reported for region picks (3–6). + public var dominantColorCount: Int { + let stored = UserDefaults.standard.integer(forKey: PreferenceKeys.pickerDominantColorCount) + if UserDefaults.standard.object(forKey: PreferenceKeys.pickerDominantColorCount) == nil { + return PreferenceDefaults.pickerDominantColorCount } - - /// Loads a captured image into the editor, creating a new annotation document. - /// - /// - Parameter image: The captured background image to annotate. - public func loadImage(_ image: CGImage) { - cancelTextEntry() - cancelBarcodeScan() - document = interactor.createDocument(from: image) - selectedNodeID = nil - ocrLines = [] - startOCR() - logger.info("Editor loaded image: \(image.width)×\(image.height)") + return min(max(stored, 3), 6) + } + + /// Called when the user cancels editing to close the editor window. + public var onClose: (() -> Void)? + + let logger = Logger(category: "editor") + let recognizeImage: @Sendable (CGImage, OCROptions) async throws -> OCRResult + let detectBarcodes: @Sendable (CGImage, [BarcodeType]) async throws -> [BarcodeResult] + let historySaver: @Sendable (CGImage, EditorHistorySaveMode, UUID?) async throws -> Void + let sourceEntryID: UUID? + private var pendingTextPoint: CGPoint? + private var editingTextNodeID: UUID? + var ocrTask: Task? + var ocrGeneration = 0 + var barcodeTask: Task? + var barcodeGeneration = 0 + + /// Creates a new editor view model. + /// + /// - Parameter interactor: The annotation interactor to use. + public init( + image: CGImage? = nil, + context: EditorCaptureContext = .standard, + interactor: AnnotationInteractor = AnnotationInteractor(), + ocrPipeline: OCRPipeline = OCRPipeline(), + barcodeEngine: VisionBarcodeEngine = VisionBarcodeEngine(), + historySaver: (@Sendable (CGImage, EditorHistorySaveMode, UUID?) async throws -> Void)? = nil + ) { + self.interactor = interactor + self.supportsVerticalTrim = context.supportsVerticalTrim + self.isVerticalTrimEnabled = context.startsInVerticalTrim + self.recognizeImage = { image, options in + try await ocrPipeline.recognize(image, options: options) } - - // MARK: - Annotation Operations - - func activateTool(_ tool: EditorTool) { - cancelTextEntry() - isVerticalTrimEnabled = false - selectedTool = tool - if tool == .ocr { - showsOCROverlay = true - } - if tool != .select { - selectNode(nil) - } - if tool != .select { - // 画框默认线框;仍播种填充色,避免手动开填充时产生透明填充。 - fillEnabled = false - if fillColor == .clear || tool == .rect { - fillColor = selectedColor - } - } + self.detectBarcodes = { image, types in + try await barcodeEngine.detect(in: image, types: types) } - - func activateVerticalTrim() { - guard supportsVerticalTrim else { return } - isVerticalTrimEnabled = true - selectedTool = .crop - selectNode(nil) + self.sourceEntryID = context.sourceEntryID + self.historySaver = historySaver ?? Self.defaultHistorySaver + if let image { + self.document = interactor.createDocument(from: image) + if context.startsInVerticalTrim { + self.selectedTool = .crop + } + startOCR() } - - func setSelectedColor(_ color: Color) { - selectedColor = color - selectedPreset = .custom - if selectedNode != nil { - updateSelectedStyle() - } else if fillEnabled { - fillColor = color - } + } + + deinit { + ocrTask?.cancel() + barcodeTask?.cancel() + } + + /// Converts the SwiftUI `Color` to a `CGColor` for the `AnnotationNode`. + public var cgColor: CGColor { + NSColor(selectedColor).cgColor + } + + /// Loads a captured image into the editor, creating a new annotation document. + /// + /// - Parameter image: The captured background image to annotate. + public func loadImage(_ image: CGImage) { + cancelTextEntry() + cancelBarcodeScan() + document = interactor.createDocument(from: image) + selectedNodeID = nil + ocrLines = [] + startOCR() + logger.info("Editor loaded image: \(image.width)×\(image.height)") + } + + // MARK: - Annotation Operations + + func activateTool(_ tool: EditorTool) { + cancelTextEntry() + isVerticalTrimEnabled = false + selectedTool = tool + if tool == .ocr { + showsOCROverlay = true } - - /// Adds a new annotation node to the document. - /// - /// - Parameter node: The annotation node to add. - public func addNode(_ node: AnnotationNode) { - guard var doc = document else { return } - let completedVerticalTrim = node.tool == .crop && isVerticalTrimEnabled - do { - try interactor.apply(node.tool, to: &doc, node: node) - document = doc - selectedNodeID = node.tool == .crop ? nil : node.id - if node.tool == .crop { - restartOCRForCurrentImage() - if completedVerticalTrim { - isVerticalTrimEnabled = false - selectedTool = .select - } - } else { - selectedTool = .select - } - logger.debug("Added node: \(node.tool.rawValue), id=\(node.id)") - } catch { - logger.error("Failed to add node: \(error.localizedDescription)") - showToast(message: error.localizedDescription, type: .error) - } + if tool != .select { + selectNode(nil) } - - /// Replaces an existing node as one undoable operation. - public func updateNode(_ node: AnnotationNode) { - guard var doc = document else { return } - doc.updateNode(node) - document = doc - selectNode(node.id) + if tool != .select { + // 画框默认线框;仍播种填充色,避免手动开填充时产生透明填充。 + fillEnabled = false + if fillColor == .clear || tool == .rect { + fillColor = selectedColor + } } - - public func removeSelectedNode() { - guard let selectedNodeID, var doc = document else { return } - doc.removeNode(by: selectedNodeID) - document = doc - self.selectedNodeID = nil + } + + func activateVerticalTrim() { + guard supportsVerticalTrim else { return } + isVerticalTrimEnabled = true + selectedTool = .crop + selectNode(nil) + } + + func setSelectedColor(_ color: Color) { + selectedColor = color + selectedPreset = .custom + if selectedNode != nil { + updateSelectedStyle() + } else if fillEnabled { + fillColor = color } - - public func selectNode(_ id: UUID?) { - selectedNodeID = id - guard let node = selectedNode else { return } - selectedColor = Color(nsColor: NSColor(cgColor: node.color ?? cgColor) ?? .red) - strokeWidth = node.lineWidth - annotationOpacity = node.opacity - fillEnabled = node.fillColor != nil - if let fill = node.fillColor, let nsFill = NSColor(cgColor: fill) { - fillColor = Color(nsColor: nsFill) + } + + /// Adds a new annotation node to the document. + /// + /// - Parameter node: The annotation node to add. + public func addNode(_ node: AnnotationNode) { + guard var doc = document else { return } + let completedVerticalTrim = node.tool == .crop && isVerticalTrimEnabled + do { + try interactor.apply(node.tool, to: &doc, node: node) + document = doc + selectedNodeID = node.tool == .crop ? nil : node.id + if node.tool == .crop { + restartOCRForCurrentImage() + if completedVerticalTrim { + isVerticalTrimEnabled = false + selectedTool = .select } - strokeStyle = node.strokeStyle - cornerRadius = node.cornerRadius - arrowStyle = node.arrowStyle - fontName = node.fontName - fontSize = node.fontSize - textAlignment = node.textAlignment - blurMode = node.blurMode - blurIntensity = node.blurIntensity - } - - public var selectedNode: AnnotationNode? { - guard let selectedNodeID else { return nil } - return document?.nodes.first { $0.id == selectedNodeID } + } else { + selectedTool = .select + } + logger.debug("Added node: \(node.tool.rawValue), id=\(node.id)") + } catch { + logger.error("Failed to add node: \(error.localizedDescription)") + showToast(message: error.localizedDescription, type: .error) } - - public func updateSelectedStyle() { - guard var node = selectedNode else { return } - node.color = cgColor - node.lineWidth = strokeWidth - node.opacity = annotationOpacity - node.fillColor = fillEnabled ? NSColor(fillColor).cgColor : nil - node.strokeStyle = strokeStyle - node.cornerRadius = cornerRadius - node.arrowStyle = arrowStyle - node.fontName = fontName - node.fontSize = fontSize - node.textAlignment = textAlignment - node.blurMode = blurMode - node.blurIntensity = blurIntensity - if node.tool == .text { - node = fittedTextNode(node) - } - updateNode(node) + } + + /// Replaces an existing node as one undoable operation. + public func updateNode(_ node: AnnotationNode) { + guard var doc = document else { return } + doc.updateNode(node) + document = doc + selectNode(node.id) + } + + public func removeSelectedNode() { + guard let selectedNodeID, var doc = document else { return } + doc.removeNode(by: selectedNodeID) + document = doc + self.selectedNodeID = nil + } + + public func selectNode(_ id: UUID?) { + selectedNodeID = id + guard let node = selectedNode else { return } + selectedColor = Color(nsColor: NSColor(cgColor: node.color ?? cgColor) ?? .red) + strokeWidth = node.lineWidth + annotationOpacity = node.opacity + fillEnabled = node.fillColor != nil + if let fill = node.fillColor, let nsFill = NSColor(cgColor: fill) { + fillColor = Color(nsColor: nsFill) } - - func applyPreset(_ preset: AnnotationStylePreset) { - selectedPreset = preset - guard preset != .custom else { return } - selectedColor = preset.color - strokeWidth = preset.lineWidth - annotationOpacity = preset.opacity - fontSize = preset.fontSize - switch preset { - case .emphasis: - fillEnabled = false - strokeStyle = .solid - case .note: - fillEnabled = true - fillColor = .black.opacity(0.75) - strokeStyle = .solid - case .subtle: - fillEnabled = false - strokeStyle = .dashed - case .monochrome: - fillEnabled = true - fillColor = .black.opacity(0.65) - strokeStyle = .solid - case .custom: - break - } - if selectedNode != nil { - updateSelectedStyle() - } + strokeStyle = node.strokeStyle + cornerRadius = node.cornerRadius + arrowStyle = node.arrowStyle + fontName = node.fontName + fontSize = node.fontSize + textAlignment = node.textAlignment + blurMode = node.blurMode + blurIntensity = node.blurIntensity + } + + public var selectedNode: AnnotationNode? { + guard let selectedNodeID else { return nil } + return document?.nodes.first { $0.id == selectedNodeID } + } + + public func updateSelectedStyle() { + guard !isEnteringText else { return } + guard var node = selectedNode else { return } + node = applyingCurrentStyle(to: node) + if node.tool == .text { + node = fittedTextNode(node) } - - public func duplicateSelectedNode() { - guard var node = selectedNode else { return } - let offset = CGPoint(x: 0.02, y: 0.02) - node = AnnotationNode( - tool: node.tool, - color: node.color, - lineWidth: node.lineWidth, - opacity: node.opacity, - fillColor: node.fillColor, - strokeStyle: node.strokeStyle, - cornerRadius: node.cornerRadius, - arrowStyle: node.arrowStyle, - points: node.points.map { CGPoint(x: min($0.x + offset.x, 1), y: min($0.y + offset.y, 1)) }, - text: node.text, - fontName: node.fontName, - fontSize: node.fontSize, - textHorizontalScale: node.textHorizontalScale, - textAlignment: node.textAlignment, - blurMode: node.blurMode, - blurIntensity: node.blurIntensity, - normalizedRect: node.normalizedRect == .zero - ? .zero - : node.normalizedRect.offsetBy(dx: offset.x, dy: offset.y) - ) - addNode(node) + updateNode(node) + } + + func applyPreset(_ preset: AnnotationStylePreset) { + selectedPreset = preset + guard preset != .custom else { return } + selectedColor = preset.color + strokeWidth = preset.lineWidth + annotationOpacity = preset.opacity + fontSize = preset.fontSize + switch preset { + case .emphasis: + fillEnabled = false + strokeStyle = .solid + case .note: + fillEnabled = true + fillColor = .black.opacity(0.75) + strokeStyle = .solid + case .subtle: + fillEnabled = false + strokeStyle = .dashed + case .monochrome: + fillEnabled = true + fillColor = .black.opacity(0.65) + strokeStyle = .solid + case .custom: + break } - - public func moveSelectedNodeInLayer(by offset: Int) { - guard let selectedNodeID, var doc = document else { return } - doc.moveNode(by: selectedNodeID, offset: offset) - document = doc + if selectedNode != nil { + updateSelectedStyle() } - - public func nudgeSelectedNode(dx deltaX: CGFloat, dy deltaY: CGFloat) { - guard var node = selectedNode else { return } - node.points = node.points.map { - CGPoint(x: min(max($0.x + deltaX, 0), 1), y: min(max($0.y + deltaY, 0), 1)) - } - if node.normalizedRect != .zero { - let moved = node.normalizedRect.offsetBy(dx: deltaX, dy: deltaY) - node.normalizedRect.origin.x = min(max(moved.origin.x, 0), 1 - moved.width) - node.normalizedRect.origin.y = min(max(moved.origin.y, 0), 1 - moved.height) - } - updateNode(node) + } + + public func duplicateSelectedNode() { + guard var node = selectedNode else { return } + let offset = CGPoint(x: 0.02, y: 0.02) + node = AnnotationNode( + tool: node.tool, + color: node.color, + lineWidth: node.lineWidth, + opacity: node.opacity, + fillColor: node.fillColor, + strokeStyle: node.strokeStyle, + cornerRadius: node.cornerRadius, + arrowStyle: node.arrowStyle, + points: node.points.map { CGPoint(x: min($0.x + offset.x, 1), y: min($0.y + offset.y, 1)) }, + text: node.text, + fontName: node.fontName, + fontSize: node.fontSize, + textHorizontalScale: node.textHorizontalScale, + textAlignment: node.textAlignment, + blurMode: node.blurMode, + blurIntensity: node.blurIntensity, + normalizedRect: node.normalizedRect == .zero + ? .zero + : node.normalizedRect.offsetBy(dx: offset.x, dy: offset.y) + ) + addNode(node) + } + + public func moveSelectedNodeInLayer(by offset: Int) { + guard let selectedNodeID, var doc = document else { return } + doc.moveNode(by: selectedNodeID, offset: offset) + document = doc + } + + public func nudgeSelectedNode(dx deltaX: CGFloat, dy deltaY: CGFloat) { + guard var node = selectedNode else { return } + node.points = node.points.map { + CGPoint(x: min(max($0.x + deltaX, 0), 1), y: min(max($0.y + deltaY, 0), 1)) } - - /// Undoes the last annotation operation. - public func undo() { - guard var doc = document else { return } - let previousImage = doc.baseImage - do { - try interactor.undo(&doc) - document = doc - if previousImage !== doc.baseImage { - restartOCRForCurrentImage() - } - } catch { - logger.warning("Undo failed: \(error.localizedDescription)") - } + if node.normalizedRect != .zero { + let moved = node.normalizedRect.offsetBy(dx: deltaX, dy: deltaY) + node.normalizedRect.origin.x = min(max(moved.origin.x, 0), 1 - moved.width) + node.normalizedRect.origin.y = min(max(moved.origin.y, 0), 1 - moved.height) } - - /// Redoes the last undone annotation operation. - public func redo() { - guard var doc = document else { return } - let previousImage = doc.baseImage - do { - try interactor.redo(&doc) - document = doc - if previousImage !== doc.baseImage { - restartOCRForCurrentImage() - } - } catch { - logger.warning("Redo failed: \(error.localizedDescription)") - } + updateNode(node) + } + + /// Undoes the last annotation operation. + public func undo() { + guard var doc = document else { return } + let previousImage = doc.baseImage + do { + try interactor.undo(&doc) + document = doc + if previousImage !== doc.baseImage { + restartOCRForCurrentImage() + } + } catch { + logger.warning("Undo failed: \(error.localizedDescription)") } - - /// Starts text entry at a normalized image coordinate. - public func beginTextEntry(at point: CGPoint) { - textEntryID = UUID() - pendingTextPoint = point - editingTextNodeID = nil - textDraft = "" - isEnteringText = true + } + + /// Redoes the last undone annotation operation. + public func redo() { + guard var doc = document else { return } + let previousImage = doc.baseImage + do { + try interactor.redo(&doc) + document = doc + if previousImage !== doc.baseImage { + restartOCRForCurrentImage() + } + } catch { + logger.warning("Redo failed: \(error.localizedDescription)") } - - public func beginTextEditing(_ node: AnnotationNode) { - guard node.tool == .text else { return } - textEntryID = UUID() - editingTextNodeID = node.id - pendingTextPoint = node.points.first ?? node.normalizedRect.origin - textDraft = node.text ?? "" - isEnteringText = true + } + + /// Starts text entry at a normalized image coordinate. + public func beginTextEntry(at point: CGPoint) { + textEntryID = UUID() + pendingTextPoint = point + editingTextNodeID = nil + textDraft = "" + isEnteringText = true + } + + public func beginTextEditing(_ node: AnnotationNode) { + guard node.tool == .text else { return } + textEntryID = UUID() + editingTextNodeID = node.id + pendingTextPoint = node.points.first ?? node.normalizedRect.origin + textDraft = node.text ?? "" + isEnteringText = true + } + + /// A draft snapshot; typing never mutates the document until commit. + var pendingTextNode: AnnotationNode? { + guard isEnteringText, let point = pendingTextPoint else { return nil } + if let editingTextNodeID, let node = document?.nodes.first(where: { $0.id == editingTextNodeID }) { + return applyingCurrentStyle(to: node) } - - /// A draft snapshot; typing never mutates the document until commit. - var pendingTextNode: AnnotationNode? { - guard isEnteringText, let point = pendingTextPoint else { return nil } - if let editingTextNodeID, - let node = document?.nodes.first(where: { $0.id == editingTextNodeID }) { return node } - return AnnotationNode( - tool: .text, color: cgColor, lineWidth: strokeWidth, opacity: annotationOpacity, - fillColor: fillEnabled ? NSColor(fillColor).cgColor : nil, - points: [point], text: textDraft, fontName: fontName, fontSize: fontSize, - textAlignment: textAlignment, normalizedRect: CGRect(origin: point, size: .zero) - ) + return AnnotationNode( + tool: .text, color: cgColor, lineWidth: strokeWidth, opacity: annotationOpacity, + fillColor: fillEnabled ? NSColor(fillColor).cgColor : nil, + points: [point], text: textDraft, fontName: fontName, fontSize: fontSize, + textAlignment: textAlignment, normalizedRect: CGRect(origin: point, size: .zero) + ) + } + + /// Commits the pending text annotation if it contains visible characters. + public func commitTextEntry() { + defer { + pendingTextPoint = nil + editingTextNodeID = nil + textDraft = "" + isEnteringText = false } - /// Commits the pending text annotation if it contains visible characters. - public func commitTextEntry() { - defer { - pendingTextPoint = nil - editingTextNodeID = nil - textDraft = "" - isEnteringText = false - } - - let text = textDraft - guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - if let editingTextNodeID, - var node = document?.nodes.first(where: { $0.id == editingTextNodeID }) { - node.text = text - updateNode(fittedTextNode(node)) - return - } - guard let point = pendingTextPoint else { return } - let node = AnnotationNode( - tool: .text, - color: cgColor, - lineWidth: strokeWidth, - opacity: annotationOpacity, - fillColor: fillEnabled ? NSColor(fillColor).cgColor : nil, - points: [point], - text: text, - fontName: fontName, - fontSize: fontSize, - textAlignment: textAlignment, - normalizedRect: CGRect(origin: point, size: .zero) - ) - addNode(fittedTextNode(node)) + let text = textDraft + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + if let editingTextNodeID, var node = document?.nodes.first(where: { $0.id == editingTextNodeID }) { + node.text = text + node = applyingCurrentStyle(to: node) + updateNode(fittedTextNode(node)) + return } - - /// Cancels the pending text annotation. - public func cancelTextEntry() { - pendingTextPoint = nil - editingTextNodeID = nil - textDraft = "" - isEnteringText = false + guard let point = pendingTextPoint else { return } + let node = AnnotationNode( + tool: .text, + color: cgColor, + lineWidth: strokeWidth, + opacity: annotationOpacity, + fillColor: fillEnabled ? NSColor(fillColor).cgColor : nil, + points: [point], + text: text, + fontName: fontName, + fontSize: fontSize, + textAlignment: textAlignment, + normalizedRect: CGRect(origin: point, size: .zero) + ) + addNode(fittedTextNode(node)) + } + + /// Cancels the pending text annotation. + public func cancelTextEntry() { + let editingTextNodeID = editingTextNodeID + pendingTextPoint = nil + self.editingTextNodeID = nil + textDraft = "" + isEnteringText = false + if let editingTextNodeID { + selectNode(editingTextNodeID) } + } - // MARK: - OCR + // MARK: - OCR - // MARK: - Color Picker + // MARK: - Color Picker - // MARK: - Barcode Recognition + // MARK: - Barcode Recognition - // MARK: - Save / Copy / Cancel + // MARK: - Save / Copy / Cancel - // MARK: - Toast + // MARK: - Toast } diff --git a/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Capture.swift b/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Capture.swift index acb41a3..151566c 100644 --- a/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Capture.swift +++ b/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Capture.swift @@ -26,6 +26,7 @@ extension CaptureViewModel { defaultValue: PreferenceDefaults.captureSelectionStyle ) ) ?? .rectangle + let overlayMode = Self.currentCaptureOverlayMode() let capturedFrames = await preCaptureAllScreens() @@ -34,6 +35,7 @@ extension CaptureViewModel { Task { @MainActor in AreaSelectionPanel.show( style: selectionStyle, + overlayMode: overlayMode, capturedFrames: capturedFrames, onColorPicked: { [weak self] color in self?.copyHexToClipboard(color) @@ -63,6 +65,15 @@ extension CaptureViewModel { } } + private static func currentCaptureOverlayMode() -> CaptureOverlayMode { + CaptureOverlayMode( + rawValue: stringPreference( + forKey: PreferenceKeys.captureOverlayMode, + defaultValue: PreferenceDefaults.captureOverlayMode + ) + ) ?? .live + } + /// Pre-captures each screen's full frame before the overlay appears so the /// frames contain no overlay windows, no cursor, and a stable sample source /// for hover/click color picking. The area rect must use Quartz global @@ -71,13 +82,12 @@ extension CaptureViewModel { /// and the selection path below (selection.screenRect is already a Quartz /// rect). AppKit screen.frame coordinates can diverge from Quartz bounds /// on secondary displays arranged above/left of the main screen. Sampling - /// only needs non-hiDPI frames, so we reuse the default 1x capture options. + /// and snapshot previews share these full-resolution frames. private func preCaptureAllScreens() async -> [CGDirectDisplayID: CGImage] { var capturedFrames: [CGDirectDisplayID: CGImage] = [:] let captureOptions = CaptureOptions( includeCursor: false, - highResolution: false, - preferredScaleFactor: 1 + highResolution: true ) for screen in NSScreen.screens { guard @@ -104,13 +114,7 @@ extension CaptureViewModel { NSPasteboard.general.clearContents() NSPasteboard.general.setString(color.hexString, forType: .string) showToast( - message: String( - format: NSLocalizedString( - "Color %@ copied", - comment: "Area color picker copy success" - ), - color.hexString - ), + message: AppLocalization.string("Color %@ copied", color.hexString), type: .success ) recordColorHistory(color, source: .area) @@ -200,7 +204,7 @@ extension CaptureViewModel { options: nil )?.first as? NSImage, let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - showToast(message: "No image found in clipboard", type: .error) + showToast(message: AppLocalization.string("No image found in clipboard"), type: .error) return } @@ -209,9 +213,17 @@ extension CaptureViewModel { } /// Opens a fresh annotation editor session for an image. - public func openEditor(with image: CGImage, captureMode: String? = nil) { + public func openEditor( + with image: CGImage, + captureMode: String? = nil, + sourceEntryID: UUID? = nil + ) { editorImage = image - editorContext = EditorCaptureContext(image: image, captureMode: captureMode) + editorContext = EditorCaptureContext.capture( + image: image, + captureMode: captureMode, + sourceEntryID: sourceEntryID + ) editorSessionID = UUID() openWindow?("editor") } @@ -259,7 +271,7 @@ extension CaptureViewModel { } catch CaptureError.permissionDenied { openWindow?("permission") } catch { - showToast(message: "Capture failed: \(error.localizedDescription)", type: .error) + showToast(message: AppLocalization.string("Capture failed: %@", error.localizedDescription), type: .error) } } @@ -274,10 +286,6 @@ extension CaptureViewModel { let modeDescription = historyModeOverride ?? Self.historyModeDescription(for: captureMode) let (shouldOpenEditor, shouldCopyImage) = resolveDestination(destination) - if shouldOpenEditor { - openEditor(with: image, captureMode: modeDescription) - } - let imageCopySucceeded = !shouldCopyImage || writeImageToClipboard(image) presentCopyFeedback( imageCopySucceeded: imageCopySucceeded, @@ -305,8 +313,11 @@ extension CaptureViewModel { case .clipboardOnly: imageCopySucceeded case .editorOnly: true } + // Save before opening the editor so the editor knows which record the + // image belongs to and can offer a reversible overwrite. + var historyEntryID: UUID? if shouldSaveToHistory { - saveToHistory( + historyEntryID = await saveToHistoryAndWait( image: image, ocrResult: ocrResult, saveFullText: saveFullText, @@ -317,6 +328,14 @@ extension CaptureViewModel { ) ) } + + if shouldOpenEditor { + openEditor( + with: image, + captureMode: modeDescription, + sourceEntryID: historyEntryID + ) + } } /// 运行 OCR 并展示条码复制建议,返回 OCR 结果。 @@ -345,25 +364,6 @@ extension CaptureViewModel { return ocrResult } - /// 将截图与 OCR 结果保存到历史记录。 - private func saveToHistory( - image: CGImage, - ocrResult: OCRResult?, - saveFullText: Bool, - captureMode: String, - source: CaptureSourceInfo - ) { - let textToStore = saveFullText ? (ocrResult?.text ?? "") : "" - let confidence = ocrResult?.confidence ?? 0 - scheduleHistorySave( - image: image, - textContent: textToStore, - confidence: confidence, - captureMode: captureMode, - source: source - ) - } - /// 根据目标解析是否打开编辑器与是否复制图片。 private func resolveDestination(_ destination: CaptureDestination) -> (openEditor: Bool, copyImage: Bool) { switch destination { @@ -396,18 +396,15 @@ extension CaptureViewModel { // (when copying was requested) is still surfaced as an error toast. if !imageCopySucceeded { showToast( - message: NSLocalizedString("Unable to copy image", comment: "Capture copy failure"), + message: AppLocalization.string("Unable to copy image"), type: .error ) } else if !shouldOpenEditor { let completionMessage: String if destination == .clipboardOnly { - completionMessage = NSLocalizedString( - "Screenshot copied to clipboard", - comment: "Direct capture copy success" - ) + completionMessage = AppLocalization.string("Screenshot copied to clipboard") } else { - completionMessage = NSLocalizedString("Capture successful", comment: "Capture completion") + completionMessage = AppLocalization.string("Capture successful") } showToast( message: completionMessage, @@ -416,41 +413,6 @@ extension CaptureViewModel { } } - private func scheduleHistorySave( - image: CGImage, - textContent: String, - confidence: Float, - captureMode: String, - source: CaptureSourceInfo - ) { - Task.detached(priority: .utility) { [weak self] in - let logger = Logger(category: "capture") - guard let history = HistoryActor.shared else { - logger.error("HistoryActor unavailable, save skipped") - await MainActor.run { - self?.showToast(message: "History unavailable; capture not saved", type: .error) - } - return - } - do { - try await history.saveCapture( - image: image, - textContent: textContent, - ocrConfidence: confidence, - captureMode: captureMode, - sourceType: .screenshot, - sourceAppName: source.appName, - sourceWindowTitle: source.windowTitle - ) - } catch { - logger.error("History save failed: \(error.localizedDescription)") - await MainActor.run { - self?.showToast(message: "History save failed", type: .error) - } - } - } - } - private func selectWindow() async -> WindowSelectionResult? { await withCheckedContinuation { continuation in var didResume = false diff --git a/App/SnapGlass/Sources/MenuBar/CaptureViewModel+History.swift b/App/SnapGlass/Sources/MenuBar/CaptureViewModel+History.swift new file mode 100644 index 0000000..5661e87 --- /dev/null +++ b/App/SnapGlass/Sources/MenuBar/CaptureViewModel+History.swift @@ -0,0 +1,99 @@ +import AppKit +import HistoryCore +import OCRCore +import SharedKit +import SwiftUI + +// MARK: - CaptureViewModel History Persistence + +extension CaptureViewModel { + /// 将截图与 OCR 结果保存到历史记录。 + private func saveToHistory( + image: CGImage, + ocrResult: OCRResult?, + saveFullText: Bool, + captureMode: String, + source: CaptureSourceInfo + ) { + let textToStore = saveFullText ? (ocrResult?.text ?? "") : "" + let confidence = ocrResult?.confidence ?? 0 + scheduleHistorySave( + image: image, + textContent: textToStore, + confidence: confidence, + captureMode: captureMode, + source: source + ) + } + + /// Synchronous history save that returns the new entry id so the editor can + /// bind its reversible-overwrite action to this capture. + func saveToHistoryAndWait( + image: CGImage, + ocrResult: OCRResult?, + saveFullText: Bool, + captureMode: String, + source: CaptureSourceInfo + ) async -> UUID? { + let textToStore = saveFullText ? (ocrResult?.text ?? "") : "" + let confidence = ocrResult?.confidence ?? 0 + guard let history = HistoryActor.shared else { + logger.error("HistoryActor unavailable, save skipped") + showToast(message: AppLocalization.string("History unavailable; capture not saved"), type: .error) + return nil + } + do { + return try await history.saveCapture( + image: image, + textContent: textToStore, + ocrConfidence: confidence, + captureMode: captureMode, + sourceType: .screenshot, + sourceAppName: source.appName, + sourceWindowTitle: source.windowTitle + ) + } catch { + logger.error("History save failed: \(error.localizedDescription)") + showToast(message: AppLocalization.string("History save failed"), type: .error) + return nil + } + } + + private func scheduleHistorySave( + image: CGImage, + textContent: String, + confidence: Float, + captureMode: String, + source: CaptureSourceInfo + ) { + Task.detached(priority: .utility) { [weak self] in + let logger = Logger(category: "capture") + guard let history = HistoryActor.shared else { + logger.error("HistoryActor unavailable, save skipped") + await MainActor.run { + self?.showToast( + message: AppLocalization.string("History unavailable; capture not saved"), + type: .error + ) + } + return + } + do { + try await history.saveCapture( + image: image, + textContent: textContent, + ocrConfidence: confidence, + captureMode: captureMode, + sourceType: .screenshot, + sourceAppName: source.appName, + sourceWindowTitle: source.windowTitle + ) + } catch { + logger.error("History save failed: \(error.localizedDescription)") + await MainActor.run { + self?.showToast(message: AppLocalization.string("History save failed"), type: .error) + } + } + } + } +} diff --git a/App/SnapGlass/Sources/MenuBar/CaptureViewModel+OCR.swift b/App/SnapGlass/Sources/MenuBar/CaptureViewModel+OCR.swift index e2f52e5..7d9b083 100644 --- a/App/SnapGlass/Sources/MenuBar/CaptureViewModel+OCR.swift +++ b/App/SnapGlass/Sources/MenuBar/CaptureViewModel+OCR.swift @@ -15,17 +15,17 @@ extension CaptureViewModel { let options = Self.currentOCROptions() let result = try await ocrPipeline.recognize(image, options: options) if result.text.isEmpty { - showToast(message: "No text found", type: .info) + showToast(message: AppLocalization.string("No text found"), type: .info) } else if copyToClipboard { NSPasteboard.general.clearContents() NSPasteboard.general.setString(result.text, forType: .string) - showToast(message: "Text copied to clipboard", type: .success) + showToast(message: AppLocalization.string("Text copied to clipboard"), type: .success) } else { - showToast(message: "OCR completed", type: .success) + showToast(message: AppLocalization.string("OCR completed"), type: .success) } return result } catch { - showToast(message: "OCR failed: \(error.localizedDescription)", type: .error) + showToast(message: AppLocalization.string("OCR failed: %@", error.localizedDescription), type: .error) return nil } } @@ -50,9 +50,9 @@ extension CaptureViewModel { func showBarcodeCopySuggestion(payload: String) { showToast( - message: NSLocalizedString("One barcode detected", comment: "Single barcode hint"), + message: AppLocalization.string("One barcode detected"), type: .info, - actionLabel: NSLocalizedString("Copy Content", comment: "Barcode copy action") + actionLabel: AppLocalization.string("Copy Content") ) { [weak self] in self?.copyBarcodePayload(payload) } @@ -62,7 +62,7 @@ extension CaptureViewModel { NSPasteboard.general.clearContents() NSPasteboard.general.setString(payload, forType: .string) showToast( - message: NSLocalizedString("Barcode copied to clipboard", comment: "Barcode copy success"), + message: AppLocalization.string("Barcode copied to clipboard"), type: .success ) } diff --git a/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Scroll.swift b/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Scroll.swift index d29b05b..e74ac52 100644 --- a/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Scroll.swift +++ b/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Scroll.swift @@ -27,7 +27,7 @@ extension CaptureViewModel { scrollSourceWindowTitle = selectedWindow.windowTitle scrollCapturedFrameCount = 1 isScrollCaptureActive = true - showToast(message: "Scroll the window, then capture the next frame", type: .info) + showToast(message: AppLocalization.string("Scroll the window, then capture the next frame"), type: .info) } catch CaptureError.permissionDenied { if let startedSession { await scrollEngine.cancelCapture(session: startedSession) @@ -60,7 +60,10 @@ extension CaptureViewModel { FrameDeduper().isDuplicate(previousFrame.image, result.image) }.value guard !isDuplicate else { - showToast(message: "No visual change detected; scroll and try again", type: .info) + showToast( + message: AppLocalization.string("No visual change detected; scroll and try again"), + type: .info + ) return } @@ -70,11 +73,17 @@ extension CaptureViewModel { timestamp: result.timestamp )) scrollCapturedFrameCount = scrollFrames.count - showToast(message: "Frame \(scrollCapturedFrameCount) captured", type: .success) + showToast( + message: AppLocalization.string("Frame %d captured", scrollCapturedFrameCount), + type: .success + ) } catch CaptureError.permissionDenied { openWindow?("permission") } catch { - showToast(message: "Scroll frame failed: \(error.localizedDescription)", type: .error) + showToast( + message: AppLocalization.string("Scroll frame failed: %@", error.localizedDescription), + type: .error + ) } } } @@ -113,6 +122,6 @@ extension CaptureViewModel { await scrollEngine.cancelCapture(session: session) } } - showToast(message: "Scrolling capture cancelled", type: .info) + showToast(message: AppLocalization.string("Scrolling capture cancelled"), type: .info) } } diff --git a/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Updates.swift b/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Updates.swift index d9d07fb..5f941c3 100644 --- a/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Updates.swift +++ b/App/SnapGlass/Sources/MenuBar/CaptureViewModel+Updates.swift @@ -8,17 +8,14 @@ extension CaptureViewModel { func presentUpdate(_ release: UpdateRelease) async { let alert = NSAlert() alert.alertStyle = .informational - alert.messageText = String( - format: NSLocalizedString("SnapGlass %@ is Available", comment: "Available update title"), - release.tagName - ) + alert.messageText = AppLocalization.string("SnapGlass %@ is Available", release.tagName) let notes = release.releaseNotes.trimmingCharacters(in: .whitespacesAndNewlines) alert.informativeText = notes.isEmpty - ? NSLocalizedString("A new version is ready to download.", comment: "Empty release notes") + ? AppLocalization.string("A new version is ready to download.") : String(notes.prefix(1_500)) - alert.addButton(withTitle: NSLocalizedString("Download Update", comment: "Download update button")) - alert.addButton(withTitle: NSLocalizedString("View on GitHub", comment: "Open release page button")) - alert.addButton(withTitle: NSLocalizedString("Later", comment: "Dismiss update button")) + alert.addButton(withTitle: AppLocalization.string("Download Update")) + alert.addButton(withTitle: AppLocalization.string("View on GitHub")) + alert.addButton(withTitle: AppLocalization.string("Later")) if #available(macOS 14.0, *) { NSApplication.shared.activate() @@ -42,18 +39,15 @@ extension CaptureViewModel { let fileURL = try await updateService.download(release) NSWorkspace.shared.activateFileViewerSelecting([fileURL]) presentInformationAlert( - title: NSLocalizedString("Update Downloaded", comment: "Update download title"), - message: String( - format: NSLocalizedString( - "%@ passed SHA-256 verification and is ready in Downloads.", - comment: "Verified update message" - ), + title: AppLocalization.string("Update Downloaded"), + message: AppLocalization.string( + "%@ passed SHA-256 verification and is ready in Downloads.", fileURL.lastPathComponent ) ) } catch { presentInformationAlert( - title: NSLocalizedString("Update Download Failed", comment: "Update download error title"), + title: AppLocalization.string("Update Download Failed"), message: error.localizedDescription, style: .warning ) @@ -69,7 +63,7 @@ extension CaptureViewModel { alert.alertStyle = style alert.messageText = title alert.informativeText = message - alert.addButton(withTitle: NSLocalizedString("OK", comment: "Alert confirmation")) + alert.addButton(withTitle: AppLocalization.string("OK")) if #available(macOS 14.0, *) { NSApplication.shared.activate() } else { diff --git a/App/SnapGlass/Sources/MenuBar/CaptureViewModel.swift b/App/SnapGlass/Sources/MenuBar/CaptureViewModel.swift index 7d1e98b..e2dc592 100644 --- a/App/SnapGlass/Sources/MenuBar/CaptureViewModel.swift +++ b/App/SnapGlass/Sources/MenuBar/CaptureViewModel.swift @@ -141,12 +141,9 @@ public final class CaptureViewModel: ObservableObject { switch result { case .upToDate(let latestVersion): presentInformationAlert( - title: NSLocalizedString("SnapGlass is Up to Date", comment: "Update status title"), - message: String( - format: NSLocalizedString( - "You are running the latest version (%@).", - comment: "Latest version message" - ), + title: AppLocalization.string("SnapGlass is Up to Date"), + message: AppLocalization.string( + "You are running the latest version (%@).", latestVersion.description ) ) @@ -155,7 +152,7 @@ public final class CaptureViewModel: ObservableObject { } } catch { presentInformationAlert( - title: NSLocalizedString("Unable to Check for Updates", comment: "Update error title"), + title: AppLocalization.string("Unable to Check for Updates"), message: error.localizedDescription, style: .warning ) diff --git a/App/SnapGlass/Sources/Overlays/AreaSelectionPanel.swift b/App/SnapGlass/Sources/Overlays/AreaSelectionPanel.swift index cfdc4f0..9263582 100644 --- a/App/SnapGlass/Sources/Overlays/AreaSelectionPanel.swift +++ b/App/SnapGlass/Sources/Overlays/AreaSelectionPanel.swift @@ -151,15 +151,12 @@ final class WindowSelectionPanel: NSPanel { container.state = .active let titleLabel = makeLabel( - NSLocalizedString("Choose a window to capture", comment: "Window picker heading"), + AppLocalization.string("Choose a window to capture"), font: .systemFont(ofSize: 14, weight: .semibold), color: .labelColor ) let subtitleLabel = makeLabel( - NSLocalizedString( - "Select a preview, then choose a still or scrolling capture.", - comment: "Window picker instruction" - ), + AppLocalization.string("Select a preview, then choose a still or scrolling capture."), font: .systemFont(ofSize: 11), color: .secondaryLabelColor ) @@ -249,7 +246,7 @@ final class WindowSelectionPanel: NSPanel { private func configureActionButtons() { configure( captureButton, - title: NSLocalizedString("Capture Window", comment: "Still window capture action"), + title: AppLocalization.string("Capture Window"), symbol: "macwindow", action: #selector(captureStillWindow) ) @@ -257,7 +254,7 @@ final class WindowSelectionPanel: NSPanel { configure( scrollCaptureButton, - title: NSLocalizedString("Scrolling Capture", comment: "Scrolling window capture action"), + title: AppLocalization.string("Scrolling Capture"), symbol: "arrow.up.arrow.down", action: #selector(captureScrollingWindow) ) diff --git a/App/SnapGlass/Sources/Overlays/AreaSelectionTypes.swift b/App/SnapGlass/Sources/Overlays/AreaSelectionTypes.swift index f3b8977..9aa2c37 100644 --- a/App/SnapGlass/Sources/Overlays/AreaSelectionTypes.swift +++ b/App/SnapGlass/Sources/Overlays/AreaSelectionTypes.swift @@ -25,6 +25,7 @@ private final class AreaSelectionSession { static func show( style: CaptureSelectionStyle, + overlayMode: CaptureOverlayMode = .live, capturedFrames: [CGDirectDisplayID: CGImage] = [:], onColorPicked: ((SampledColor) -> Void)? = nil, onComplete: @escaping (AreaSelectionResult?) -> Void @@ -37,7 +38,12 @@ private final class AreaSelectionSession { let session = AreaSelectionSession(onComplete: onComplete) session.capturedFrames = capturedFrames retainedSessions.append(session) - session.present(on: screens, style: style, onColorPicked: onColorPicked) + session.present( + on: screens, + style: style, + overlayMode: overlayMode, + onColorPicked: onColorPicked + ) } private init(onComplete: @escaping (AreaSelectionResult?) -> Void) { @@ -47,12 +53,14 @@ private final class AreaSelectionSession { private func present( on screens: [NSScreen], style: CaptureSelectionStyle, + overlayMode: CaptureOverlayMode, onColorPicked: ((SampledColor) -> Void)? ) { panels = screens.map { screen in AreaSelectionPanel( screen: screen, style: style, + overlayMode: overlayMode, capturedFrames: capturedFrames, onColorPicked: onColorPicked ) { [weak self] result in @@ -85,12 +93,14 @@ final class AreaSelectionPanel: NSPanel { static func show( style: CaptureSelectionStyle, + overlayMode: CaptureOverlayMode = .live, capturedFrames: [CGDirectDisplayID: CGImage] = [:], onColorPicked: ((SampledColor) -> Void)? = nil, onComplete: @escaping (AreaSelectionResult?) -> Void ) { AreaSelectionSession.show( style: style, + overlayMode: overlayMode, capturedFrames: capturedFrames, onColorPicked: onColorPicked, onComplete: onComplete @@ -100,6 +110,7 @@ final class AreaSelectionPanel: NSPanel { fileprivate init( screen: NSScreen, style: CaptureSelectionStyle, + overlayMode: CaptureOverlayMode, capturedFrames: [CGDirectDisplayID: CGImage], onColorPicked: ((SampledColor) -> Void)?, onComplete: @escaping (AreaSelectionResult?) -> Void @@ -108,6 +119,7 @@ final class AreaSelectionPanel: NSPanel { let trackingView = AreaTrackingView( frame: CGRect(origin: .zero, size: contentSize), style: style, + overlayMode: overlayMode, screen: screen, capturedFrames: capturedFrames, onColorPicked: onColorPicked diff --git a/App/SnapGlass/Sources/Overlays/AreaTrackingView+Drawing.swift b/App/SnapGlass/Sources/Overlays/AreaTrackingView+Drawing.swift index a937748..73a1e63 100644 --- a/App/SnapGlass/Sources/Overlays/AreaTrackingView+Drawing.swift +++ b/App/SnapGlass/Sources/Overlays/AreaTrackingView+Drawing.swift @@ -12,36 +12,117 @@ extension AreaTrackingView { super.draw(dirtyRect) guard let context = NSGraphicsContext.current?.cgContext else { return } + switch overlayMode { + case .live: + drawLiveBackground(in: context) + case .snapshot: + drawSnapshotBackground(in: context) + } + + drawSelectionOutline(in: context) + + if !selectionRect.isEmpty { + drawSizeLabel(for: selectionRect) + if phase == .adjusting { drawActionHint(for: selectionRect) } + } + if phase != .choosingAction { + drawCrosshair(at: hoverPoint) + if let hoverColor { drawHoverColorLabel(for: hoverColor, near: hoverPoint) } + } + } + + private func drawLiveBackground(in context: CGContext) { context.setFillColor(NSColor.black.withAlphaComponent(0.32).cgColor) context.fill(bounds) + context.saveGState() + guard clipToSelection(in: context) else { + context.restoreGState() + return + } + context.clear(bounds) + context.restoreGState() + } + + private func drawSnapshotBackground(in context: CGContext) { + guard let snapshotFrame else { + context.setFillColor(NSColor.black.cgColor) + context.fill(bounds) + return + } + + draw(snapshotFrame, in: context) + context.setFillColor(NSColor.black.withAlphaComponent(0.32).cgColor) + context.fill(bounds) + + context.saveGState() + guard clipToSelection(in: context) else { + context.restoreGState() + return + } + draw(snapshotFrame, in: context) + context.restoreGState() + } + + private var snapshotFrame: CGImage? { + guard bounds.width > 0, bounds.height > 0, + let displayID = screen.deviceDescription[ + NSDeviceDescriptionKey("NSScreenNumber") + ] as? CGDirectDisplayID, + let frame = capturedFrames[displayID] + else { return nil } + + let viewAspectRatio = bounds.width / bounds.height + let frameAspectRatio = CGFloat(frame.width) / CGFloat(frame.height) + let relativeDifference = abs(frameAspectRatio - viewAspectRatio) / viewAspectRatio + return relativeDifference <= 0.02 ? frame : nil + } + + private func draw(_ image: CGImage, in context: CGContext) { + context.saveGState() + context.setBlendMode(.copy) + context.interpolationQuality = .none + context.translateBy(x: bounds.minX, y: bounds.minY) + context.scaleBy( + x: bounds.width / CGFloat(image.width), + y: bounds.height / CGFloat(image.height) + ) + context.draw( + image, + in: CGRect( + x: 0, + y: 0, + width: CGFloat(image.width), + height: CGFloat(image.height) + ) + ) + context.restoreGState() + } + + private func clipToSelection(in context: CGContext) -> Bool { if style == .freeform, freeformPoints.count >= 2 { - let path = freeformPath() - context.saveGState() - context.addPath(path) + context.addPath(freeformPath()) context.clip() - context.clear(bounds) - context.restoreGState() + return true + } + guard !selectionRect.isEmpty else { return false } + context.clip(to: selectionRect) + return true + } + + private func drawSelectionOutline(in context: CGContext) { + if style == .freeform, freeformPoints.count >= 2 { + let path = freeformPath() context.setStrokeColor(NSColor.white.cgColor) context.setLineWidth(2) context.addPath(path) context.strokePath() } else if !selectionRect.isEmpty { - context.clear(selectionRect) context.setStrokeColor(NSColor.white.cgColor) context.setLineWidth(2) context.stroke(selectionRect.insetBy(dx: -1, dy: -1)) if phase == .adjusting { drawHandles() } } - - if !selectionRect.isEmpty { - drawSizeLabel(for: selectionRect) - if phase == .adjusting { drawActionHint(for: selectionRect) } - } - if phase != .choosingAction { - drawCrosshair(at: hoverPoint) - if let hoverColor { drawHoverColorLabel(for: hoverColor, near: hoverPoint) } - } } /// Samples the pixel directly beneath a view point from the pre-captured @@ -151,10 +232,7 @@ extension AreaTrackingView { } private func drawActionHint(for rect: CGRect) { - let text = NSLocalizedString( - "Return / double-click to choose an action", - comment: "Area capture selection confirmation hint" - ) + let text = AppLocalization.string("Return / double-click to choose an action") drawLabel(text, at: CGPoint(x: rect.midX, y: rect.minY - 18)) } diff --git a/App/SnapGlass/Sources/Overlays/AreaTrackingView.swift b/App/SnapGlass/Sources/Overlays/AreaTrackingView.swift index 4b4024f..8bc595c 100644 --- a/App/SnapGlass/Sources/Overlays/AreaTrackingView.swift +++ b/App/SnapGlass/Sources/Overlays/AreaTrackingView.swift @@ -46,46 +46,34 @@ private final class CaptureActionBarView: NSVisualEffectView { setAccessibilityElement(true) setAccessibilityRole(.group) setAccessibilityLabel( - NSLocalizedString("Screenshot Actions", comment: "Capture action bar accessibility label") + AppLocalization.string("Screenshot Actions") ) } private func configureActionButtons() { configure( backButton, - title: NSLocalizedString("Back", comment: "Return from capture actions to selection adjustment"), + title: AppLocalization.string("Back"), symbol: "chevron.backward", - toolTip: NSLocalizedString( - "Return to selection adjustments", - comment: "Capture action bar back button help" - ), + toolTip: AppLocalization.string("Return to selection adjustments"), action: #selector(back) ) backButton.keyEquivalent = "\u{1b}" configure( copyButton, - title: NSLocalizedString("Copy Image", comment: "Capture action that copies the selected area"), + title: AppLocalization.string("Copy Image"), symbol: "doc.on.doc", - toolTip: NSLocalizedString( - "Copy the screenshot to the clipboard", - comment: "Capture action bar copy button help" - ), + toolTip: AppLocalization.string("Copy the screenshot to the clipboard"), action: #selector(copyImage) ) copyButton.keyEquivalent = "\r" configure( editButton, - title: NSLocalizedString( - "Edit Screenshot", - comment: "Capture action that opens the selected area in the editor" - ), + title: AppLocalization.string("Edit Screenshot"), symbol: "pencil.and.outline", - toolTip: NSLocalizedString( - "Open the screenshot in the annotation editor", - comment: "Capture action bar edit button help" - ), + toolTip: AppLocalization.string("Open the screenshot in the annotation editor"), action: #selector(editScreenshot) ) editButton.keyEquivalent = "e" @@ -155,6 +143,7 @@ final class AreaTrackingView: NSView { } let style: CaptureSelectionStyle + let overlayMode: CaptureOverlayMode let screen: NSScreen let capturedFrames: [CGDirectDisplayID: CGImage] private let onColorPicked: ((SampledColor) -> Void)? @@ -171,11 +160,13 @@ final class AreaTrackingView: NSView { init( frame frameRect: NSRect, style: CaptureSelectionStyle, + overlayMode: CaptureOverlayMode, screen: NSScreen, capturedFrames: [CGDirectDisplayID: CGImage], onColorPicked: ((SampledColor) -> Void)? ) { self.style = style + self.overlayMode = overlayMode self.screen = screen self.capturedFrames = capturedFrames self.onColorPicked = onColorPicked diff --git a/App/SnapGlass/Sources/Overlays/WindowSelectionCoordinator.swift b/App/SnapGlass/Sources/Overlays/WindowSelectionCoordinator.swift index 632509c..0fcdc9f 100644 --- a/App/SnapGlass/Sources/Overlays/WindowSelectionCoordinator.swift +++ b/App/SnapGlass/Sources/Overlays/WindowSelectionCoordinator.swift @@ -14,11 +14,11 @@ struct SelectableWindow { var displayName: String { let app = appName?.isEmpty == false - ? appName ?? String(localized: "Unknown App") - : String(localized: "Unknown App") + ? appName ?? AppLocalization.string("Unknown App") + : AppLocalization.string("Unknown App") let title = windowTitle?.isEmpty == false - ? windowTitle ?? String(localized: "Untitled") - : String(localized: "Untitled") + ? windowTitle ?? AppLocalization.string("Untitled") + : AppLocalization.string("Untitled") return "\(app) — \(title)" } diff --git a/App/SnapGlass/Sources/WindowLifecycleState.swift b/App/SnapGlass/Sources/WindowLifecycleState.swift index 200b9c7..82127d9 100644 --- a/App/SnapGlass/Sources/WindowLifecycleState.swift +++ b/App/SnapGlass/Sources/WindowLifecycleState.swift @@ -8,8 +8,11 @@ struct WindowLifecycleState: Equatable { private(set) var pendingPresentations: [String: Int] = [:] private var nextGeneration = 0 + /// 刻意只看 `pendingPresentations`:SwiftUI 关闭 `Window` 后会保留其 `NSWindow`, + /// `register()` 可能在 `willClose` 之后重登记,若把 `registeredWindowIDs` 纳入此门 + /// 会永久阻塞降级(Dock 图标常驻)。所有打开路径均经 `present()`,pending 足以防误降级。 var shouldUseAccessoryPolicy: Bool { - registeredWindowIDs.isEmpty && pendingPresentations.isEmpty + pendingPresentations.isEmpty } mutating func beginPresentation(id: String) -> PresentationStart { diff --git a/App/SnapGlass/Sources/Windows/CapturePreferencesView.swift b/App/SnapGlass/Sources/Windows/CapturePreferencesView.swift new file mode 100644 index 0000000..891a10c --- /dev/null +++ b/App/SnapGlass/Sources/Windows/CapturePreferencesView.swift @@ -0,0 +1,108 @@ +import SharedKit +import SwiftUI + +struct CapturePreferencesView: View { + @AppStorage(PreferenceKeys.captureOpenEditor) + private var openEditor = PreferenceDefaults.captureOpenEditor + @AppStorage(PreferenceKeys.captureCopyToClipboard) + private var copyToClipboard = PreferenceDefaults.captureCopyToClipboard + @AppStorage(PreferenceKeys.captureIncludeCursor) + private var includeCursor = PreferenceDefaults.captureIncludeCursor + @AppStorage(PreferenceKeys.captureAutoOCR) + private var autoOCR = PreferenceDefaults.captureAutoOCR + @AppStorage(PreferenceKeys.captureCopyOCRText) + private var copyOCRText = PreferenceDefaults.captureCopyOCRText + @AppStorage(PreferenceKeys.captureSelectionStyle) + private var selectionStyle = PreferenceDefaults.captureSelectionStyle + @AppStorage(PreferenceKeys.captureOverlayMode) + private var overlayMode = PreferenceDefaults.captureOverlayMode + @AppStorage(PreferenceKeys.captureHighResolution) + private var highResolution = PreferenceDefaults.captureHighResolution + @AppStorage(PreferenceKeys.captureImageFormat) + private var imageFormat = PreferenceDefaults.captureImageFormat + @AppStorage(PreferenceKeys.captureJPEGQuality) + private var jpegQuality = PreferenceDefaults.captureJPEGQuality + @AppStorage(PreferenceKeys.pickerDominantColorCount) + private var pickerDominantColorCount = PreferenceDefaults.pickerDominantColorCount + + var body: some View { + ScrollView { + PreferencesCardGrid { + afterCaptureCard + + imageCard + } + } + } + + private var afterCaptureCard: some View { + PreferencesCard { + PreferencesCardHeader(systemImage: "camera.viewfinder", title: "After Capture") + + PreferencesCardCaption( + text: """ + Area captures ask whether to copy or edit when you confirm the selection. \ + These settings apply to window, fullscreen, and scrolling captures. + """ + ) + + Toggle("Open annotation editor", isOn: $openEditor) + TextEntryPreferencesView() + Toggle("Copy image to clipboard", isOn: $copyToClipboard) + Toggle("Run OCR automatically", isOn: $autoOCR) + Toggle("Replace clipboard with recognized text", isOn: $copyOCRText) + .disabled(!autoOCR) + .help("When enabled, recognized text is copied automatically after capture.") + } + } + + private var imageCard: some View { + PreferencesCard { + PreferencesCardHeader(systemImage: "photo", title: "Image") + + Toggle("Include pointer", isOn: $includeCursor) + Toggle("Native Retina resolution", isOn: $highResolution) + + Picker("Selection style", selection: $selectionStyle) { + Text("Rectangle").tag(CaptureSelectionStyle.rectangle.rawValue) + Text("Freeform").tag(CaptureSelectionStyle.freeform.rawValue) + } + .pickerStyle(.segmented) + + Picker("Overlay preview", selection: $overlayMode) { + Text("Live screen").tag(CaptureOverlayMode.live.rawValue) + Text("Static snapshot").tag(CaptureOverlayMode.snapshot.rawValue) + } + .pickerStyle(.segmented) + + PreferencesCardCaption( + text: "Static snapshot freezes the screen when selection starts; unavailable screens appear black." + ) + + Picker("Saved image format", selection: $imageFormat) { + Text("PNG (lossless)").tag(ImageFileFormat.png.rawValue) + Text("JPEG (smaller)").tag(ImageFileFormat.jpeg.rawValue) + } + .pickerStyle(.menu) + + Picker("Number of dominant colors", selection: $pickerDominantColorCount) { + ForEach(3...6, id: \.self) { count in + Text("\(count)").tag(count) + } + } + .pickerStyle(.menu) + + if imageFormat == ImageFileFormat.jpeg.rawValue { + VStack(alignment: .leading) { + LabeledContent("JPEG quality") { + Text(jpegQuality, format: .percent.precision(.fractionLength(0))) + .monospacedDigit() + } + Slider(value: $jpegQuality, in: 0.5...1, step: 0.05) + } + } + + PreferencesCardCaption(text: "Freeform captures always use PNG to preserve transparency.") + } + } +} diff --git a/App/SnapGlass/Sources/Windows/HistoryPreferencesView.swift b/App/SnapGlass/Sources/Windows/HistoryPreferencesView.swift index ed34b18..330129d 100644 --- a/App/SnapGlass/Sources/Windows/HistoryPreferencesView.swift +++ b/App/SnapGlass/Sources/Windows/HistoryPreferencesView.swift @@ -80,7 +80,7 @@ struct HistoryPreferencesView: View { TriValueControl( title: "Maximum screenshots", - unit: String(localized: "items"), + unit: "items", presets: [50, 100, 200, 500, 1000], range: 10...5_000, value: maxItems @@ -93,7 +93,7 @@ struct HistoryPreferencesView: View { if !keepIndefinitely { TriValueControl( title: "Retention period", - unit: String(localized: "days"), + unit: "days", presets: [7, 30, 90, 365], range: 1...3_650, value: retentionDays @@ -154,7 +154,7 @@ struct HistoryPreferencesView: View { if colorHistoryEnabled { TriValueControl( title: "Maximum color entries", - unit: String(localized: "items"), + unit: "items", presets: [50, 100, 200, 500], range: 10...5_000, value: colorHistoryMaxItems diff --git a/App/SnapGlass/Sources/Windows/HistoryScreenshotCard.swift b/App/SnapGlass/Sources/Windows/HistoryScreenshotCard.swift index c549fe4..b71444e 100644 --- a/App/SnapGlass/Sources/Windows/HistoryScreenshotCard.swift +++ b/App/SnapGlass/Sources/Windows/HistoryScreenshotCard.swift @@ -45,8 +45,10 @@ struct HistoryScreenshotCard: View { .buttonStyle(.plain) .padding(6) .disabled(isUpdatingFavourite) - .help(entry.isFavourite ? "Remove favourite" : "Add favourite") - .accessibilityLabel(entry.isFavourite ? "Remove favourite" : "Add favourite") + .help(Text(entry.isFavourite ? LocalizedStringKey("Remove favourite") : LocalizedStringKey("Add favourite"))) + .accessibilityLabel( + Text(entry.isFavourite ? LocalizedStringKey("Remove favourite") : LocalizedStringKey("Add favourite")) + ) } Text(entry.timestamp, format: .dateTime.year().month().day().hour().minute()) .font(.caption) diff --git a/App/SnapGlass/Sources/Windows/HistoryStorageDashboard.swift b/App/SnapGlass/Sources/Windows/HistoryStorageDashboard.swift index 5e4b673..25dfc47 100644 --- a/App/SnapGlass/Sources/Windows/HistoryStorageDashboard.swift +++ b/App/SnapGlass/Sources/Windows/HistoryStorageDashboard.swift @@ -189,12 +189,7 @@ struct HistoryStorageDashboard: View { .tint(ratio > 0.85 ? .red : (ratio > 0.6 ? .orange : .green)) .frame(width: 90, height: 90) - Text( - String( - format: String(localized: "of %@ GB limit"), - capGB.formatted(.number.precision(.fractionLength(1))) - ) - ) + Text("of \(capGB.formatted(.number.precision(.fractionLength(1)))) GB limit") .font(.caption) .foregroundStyle(.secondary) } diff --git a/App/SnapGlass/Sources/Windows/HistoryView+Actions.swift b/App/SnapGlass/Sources/Windows/HistoryView+Actions.swift index b48c900..a16f00a 100644 --- a/App/SnapGlass/Sources/Windows/HistoryView+Actions.swift +++ b/App/SnapGlass/Sources/Windows/HistoryView+Actions.swift @@ -110,13 +110,7 @@ extension HistoryView { NSPasteboard.general.clearContents() NSPasteboard.general.setString(entry.hexString, forType: .string) let toast = ToastMessage( - message: String( - format: NSLocalizedString( - "Color %@ copied", - comment: "History color copy success" - ), - entry.hexString - ), + message: AppLocalization.string("Color %@ copied", entry.hexString), type: .success ) toastMessage = toast @@ -132,17 +126,22 @@ extension HistoryView { guard let history else { return } do { guard let data = try await history.imageData(for: entry.id) else { - errorMessage = "The original screenshot is no longer available. " - + "It may have been removed by the retention policy." + errorMessage = AppLocalization.string( + "The original screenshot is no longer available. It may have been removed by the retention policy." + ) return } guard let source = CGImageSourceCreateWithData(data as CFData, nil), let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else { - errorMessage = String(localized: "The stored screenshot could not be decoded.") + errorMessage = AppLocalization.string("The stored screenshot could not be decoded.") return } - captureViewModel.openEditor(with: image, captureMode: entry.captureMode) + captureViewModel.openEditor( + with: image, + captureMode: entry.captureMode, + sourceEntryID: entry.id + ) } catch { errorMessage = error.localizedDescription } @@ -175,6 +174,27 @@ extension HistoryView { } } + func restoreOriginal(_ entry: HistoryEntry) async { + guard let history else { return } + do { + try await history.restoreOriginal(id: entry.id) + await loadEntries() + let toast = ToastMessage( + message: AppLocalization.string("Original image restored"), + type: .success + ) + toastMessage = toast + Task { + try? await Task.sleep(for: .seconds(3)) + if toastMessage?.id == toast.id { + toastMessage = nil + } + } + } catch { + errorMessage = error.localizedDescription + } + } + enum ExportFormat { case json, csv, plaintext } diff --git a/App/SnapGlass/Sources/Windows/HistoryView.swift b/App/SnapGlass/Sources/Windows/HistoryView.swift index a468095..74f660e 100644 --- a/App/SnapGlass/Sources/Windows/HistoryView.swift +++ b/App/SnapGlass/Sources/Windows/HistoryView.swift @@ -300,6 +300,14 @@ struct HistoryView: View { Label("Open in Editor", systemImage: "pencil.and.outline") } + if entry.canRestoreOriginal { + Button { + Task { await restoreOriginal(entry) } + } label: { + Label("Restore Original Image", systemImage: "arrow.uturn.backward.circle") + } + } + Divider() Button { diff --git a/App/SnapGlass/Sources/Windows/PreferencesView.swift b/App/SnapGlass/Sources/Windows/PreferencesView.swift index 38111c2..b2491bc 100644 --- a/App/SnapGlass/Sources/Windows/PreferencesView.swift +++ b/App/SnapGlass/Sources/Windows/PreferencesView.swift @@ -238,100 +238,6 @@ struct GeneralPreferencesView: View { } } -struct CapturePreferencesView: View { - @AppStorage(PreferenceKeys.captureOpenEditor) - private var openEditor = PreferenceDefaults.captureOpenEditor - @AppStorage(PreferenceKeys.captureCopyToClipboard) - private var copyToClipboard = PreferenceDefaults.captureCopyToClipboard - @AppStorage(PreferenceKeys.captureIncludeCursor) - private var includeCursor = PreferenceDefaults.captureIncludeCursor - @AppStorage(PreferenceKeys.captureAutoOCR) - private var autoOCR = PreferenceDefaults.captureAutoOCR - @AppStorage(PreferenceKeys.captureCopyOCRText) - private var copyOCRText = PreferenceDefaults.captureCopyOCRText - @AppStorage(PreferenceKeys.captureSelectionStyle) - private var selectionStyle = PreferenceDefaults.captureSelectionStyle - @AppStorage(PreferenceKeys.captureHighResolution) - private var highResolution = PreferenceDefaults.captureHighResolution - @AppStorage(PreferenceKeys.captureImageFormat) - private var imageFormat = PreferenceDefaults.captureImageFormat - @AppStorage(PreferenceKeys.captureJPEGQuality) - private var jpegQuality = PreferenceDefaults.captureJPEGQuality - @AppStorage(PreferenceKeys.pickerDominantColorCount) - private var pickerDominantColorCount = PreferenceDefaults.pickerDominantColorCount - - var body: some View { - ScrollView { - PreferencesCardGrid { - afterCaptureCard - - imageCard - } - } - } - - private var afterCaptureCard: some View { - PreferencesCard { - PreferencesCardHeader(systemImage: "camera.viewfinder", title: "After Capture") - - PreferencesCardCaption( - text: """ - Area captures ask whether to copy or edit when you confirm the selection. \ - These settings apply to window, fullscreen, and scrolling captures. - """ - ) - - Toggle("Open annotation editor", isOn: $openEditor) - TextEntryPreferencesView() - Toggle("Copy image to clipboard", isOn: $copyToClipboard) - Toggle("Run OCR automatically", isOn: $autoOCR) - Toggle("Replace clipboard with recognized text", isOn: $copyOCRText) - .disabled(!autoOCR) - .help("When enabled, recognized text is copied automatically after capture.") - } - } - - private var imageCard: some View { - PreferencesCard { - PreferencesCardHeader(systemImage: "photo", title: "Image") - - Toggle("Include pointer", isOn: $includeCursor) - Toggle("Native Retina resolution", isOn: $highResolution) - - Picker("Selection style", selection: $selectionStyle) { - Text("Rectangle").tag(CaptureSelectionStyle.rectangle.rawValue) - Text("Freeform").tag(CaptureSelectionStyle.freeform.rawValue) - } - .pickerStyle(.segmented) - - Picker("Saved image format", selection: $imageFormat) { - Text("PNG (lossless)").tag(ImageFileFormat.png.rawValue) - Text("JPEG (smaller)").tag(ImageFileFormat.jpeg.rawValue) - } - .pickerStyle(.menu) - - Picker("Number of dominant colors", selection: $pickerDominantColorCount) { - ForEach(3...6, id: \.self) { count in - Text("\(count)").tag(count) - } - } - .pickerStyle(.menu) - - if imageFormat == ImageFileFormat.jpeg.rawValue { - VStack(alignment: .leading) { - LabeledContent("JPEG quality") { - Text(jpegQuality, format: .percent.precision(.fractionLength(0))) - .monospacedDigit() - } - Slider(value: $jpegQuality, in: 0.5...1, step: 0.05) - } - } - - PreferencesCardCaption(text: "Freeform captures always use PNG to preserve transparency.") - } - } -} - struct OCRPreferencesView: View { @AppStorage(PreferenceKeys.ocrLanguagePriority) private var languagePriority = PreferenceDefaults.ocrLanguagePriority diff --git a/App/SnapGlass/Sources/Windows/TriValueControl.swift b/App/SnapGlass/Sources/Windows/TriValueControl.swift index 6ac01e2..ccf6fee 100644 --- a/App/SnapGlass/Sources/Windows/TriValueControl.swift +++ b/App/SnapGlass/Sources/Windows/TriValueControl.swift @@ -5,7 +5,7 @@ import SwiftUI /// retention count and days settings. struct TriValueControl: View { let title: LocalizedStringKey - let unit: String + let unit: LocalizedStringKey let presets: [Int] let range: ClosedRange let value: Int @@ -17,10 +17,13 @@ struct TriValueControl: View { var body: some View { VStack(alignment: .leading, spacing: 12) { LabeledContent(title) { - Text("\(value.formatted()) \(unit)") - .font(.body.weight(.semibold)) - .monospacedDigit() - .foregroundStyle(.primary) + HStack(spacing: 4) { + Text(value.formatted()) + .font(.body.weight(.semibold)) + .monospacedDigit() + .foregroundStyle(.primary) + Text(unit) + } } HStack(spacing: 6) { @@ -49,7 +52,7 @@ struct TriValueControl: View { .foregroundStyle(isSelected ? Color.accentColor : Color.primary) } .buttonStyle(.plain) - .help("\(preset.formatted()) \(unit)") + .help(Text(preset.formatted()) + Text(" ") + Text(unit)) } } diff --git a/App/SnapGlass/Tests/WindowLifecycleStateTests.swift b/App/SnapGlass/Tests/WindowLifecycleStateTests.swift index 0a2472a..814739f 100644 --- a/App/SnapGlass/Tests/WindowLifecycleStateTests.swift +++ b/App/SnapGlass/Tests/WindowLifecycleStateTests.swift @@ -33,7 +33,17 @@ struct WindowLifecycleStateTests { #expect(state.pendingPresentations.count == 1) } - @Test("Closing one window cannot downgrade while another presentation is pending") + @Test("Registered windows alone cannot block accessory policy") + func registeredWindowsDoNotBlockAccessoryPolicy() { + var state = WindowLifecycleState() + + state.registerWindow(id: "history") + state.registerWindow(id: "preferences") + + #expect(state.shouldUseAccessoryPolicy) + } + + @Test("A pending presentation prevents accessory downgrade until it completes") func pendingPresentationPreventsAccessoryDowngrade() { var state = WindowLifecycleState() @@ -45,7 +55,7 @@ struct WindowLifecycleStateTests { state.registerWindow(id: "preferences") state.completePresentation(id: "preferences") - #expect(!state.shouldUseAccessoryPolicy) + #expect(state.shouldUseAccessoryPolicy) } @Test("A stale timeout cannot clear a newer presentation") diff --git a/App/SnapGlass/Tests/WindowPresentationCoordinatorTests.swift b/App/SnapGlass/Tests/WindowPresentationCoordinatorTests.swift index b5730a1..4e2974a 100644 --- a/App/SnapGlass/Tests/WindowPresentationCoordinatorTests.swift +++ b/App/SnapGlass/Tests/WindowPresentationCoordinatorTests.swift @@ -42,6 +42,24 @@ struct WindowPresentationCoordinatorTests { #expect(activationController.policyChanges == [.regular, .accessory]) #expect(activationController.activationPolicy == .accessory) + #expect(activationController.deactivateCount == 1) + } + + @Test("Downgrading to accessory deactivates the app to drop the Dock icon") + func accessoryDowngradeDeactivatesApp() async throws { + let activationController = ActivationControllerSpy( + activationPolicy: .accessory, + hasVisibleUserFacingWindow: false + ) + let coordinator = WindowPresentationCoordinator( + activationController: activationController, + presentationTimeout: .milliseconds(10) + ) + + coordinator.present(id: "editor") {} + try await Task.sleep(for: .milliseconds(50)) + + #expect(activationController.deactivateCount == 1) } @Test("A deferred downgrade is retried after the visible window disappears") @@ -71,6 +89,7 @@ private final class ActivationControllerSpy: ApplicationActivationControlling { var isActive = false var hasVisibleUserFacingWindow: Bool private(set) var policyChanges: [NSApplication.ActivationPolicy] = [] + private(set) var deactivateCount = 0 init( activationPolicy: NSApplication.ActivationPolicy, @@ -89,4 +108,9 @@ private final class ActivationControllerSpy: ApplicationActivationControlling { func requestActivation() { isActive = true } + + func deactivate() { + isActive = false + deactivateCount += 1 + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6208367..741725b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.8.1] - 2026-09-15 + +### Fixed +- 关闭最后一个窗口后 Dock 图标常驻:激活策略降级门不再被 SwiftUI 保留的已注册 `NSWindow` 钉死;降级时显式 `deactivate()` 以移除 active 状态下的 Dock 图标;窗口成为 key 时补齐晋升到 regular 的对称路径。 +- 设置窗口语言不一致:设置内所有文本(含历史/取色卡片、开发者页、窗口标题与应用菜单)统一跟随应用内语言,不再部分跟随系统语言。 +- 补齐四语言(en / zh-Hans / ja / ko)界面文案:开发者模式、诊断、存入历史、还原原图、滚动截图与 OCR 提示等此前缺失的键,消除英文残留。 +- toast / 弹窗 / 窗口选择面板 / 截图操作条中硬编码的英文文案改为本地化调用。 + +### Changed +- 新增 `AppLocalization` 与 `AppLanguage.resourceIdentifier`:为 SwiftUI 环境之外的 Foundation 文本(toast、NSAlert、NSMenu、AppKit 面板、画布绘制)按应用语言解析 `.lproj`。 +- 新增 `scripts/check-localization.sh`:校验四语言键一致、占位符一致、无冲突重复键、源码引用键均存在。 + +## [0.8.0] - 2026-09-14 + +### Added +- 编辑图写入历史:标注编辑器新增「存入历史」,可选择新建记录或覆盖原图记录;覆盖后原图加密件永久保留,历史中可通过「还原原图」恢复(重启后仍有效)。 +- 区域截图新增实时画面与静态快照覆盖层模式;静态模式会冻结选区背景,屏幕预捕获不可用时使用纯黑回退。 + +### Fixed +- 修复静态快照覆盖层画面上下翻转的问题,并改用全分辨率预捕获帧作为预览背景,选区背景清晰度与最终截图一致。 +- 修复画布内文本编辑时 Inspector 样式不实时更新、取消编辑未恢复原样式,以及窄图长文本未按可用宽度换行导致的裁剪问题。 +- 修复文字标注在缩小预览下整行不绘制的问题(预览渲染改为按原图尺寸排版再整体缩放,保证编辑态与导出文本一致)。 + ## [0.7.0] - 2026-09-07 ### Added @@ -193,7 +216,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - 移除 GUI App 的 Automation 窗口、`snapglass://` URL Scheme、App Intents 产品依赖和 CLI 构建目标 - 移除临时构建产物目录 `output/`,统一收敛到 `release/` -[Unreleased]: https://github.com/blackkcold/snapocr/compare/v0.7.0...HEAD +[Unreleased]: https://github.com/blackkcold/snapocr/compare/v0.8.1...HEAD +[0.8.1]: https://github.com/blackkcold/snapocr/compare/v0.8.0...v0.8.1 +[0.8.0]: https://github.com/blackkcold/snapocr/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/blackkcold/snapocr/compare/v0.6.1...v0.7.0 [0.6.1]: https://github.com/blackkcold/snapocr/compare/v0.6.0...v0.6.1 [0.6.0]: https://github.com/blackkcold/snapocr/compare/v0.5.6...v0.6.0 diff --git a/Docs/AGENTS.md b/Docs/AGENTS.md index 600542b..0774478 100644 --- a/Docs/AGENTS.md +++ b/Docs/AGENTS.md @@ -115,6 +115,21 @@ Platform Adapter (macOS: Vision, SCK, NSPasteboard) - 直接 push 到 `main` 分支 - 修改 `.xcodeproj`(由 XcodeGen 生成,禁止手动编辑) - 使用 `output/` 目录存放构建产物(统一归档到 `release/vX.Y.Z/`) +- 在界面代码中使用 `NSLocalizedString` / `String(localized:)`(跟随系统语言,会破坏应用内语言一致性) +- 新增界面文案而不补四语言键 + +--- + +## 本地化约定 + +界面语言由设置中的「应用语言」决定,文案存于 `App/SnapGlass/Resources/.lproj/Localizable.strings`(`en` / `zh-Hans` / `ja` / `ko`)。 + +- 视图树内文本用字符串字面量(`LocalizedStringKey`),跟随注入的 `\.locale`。 +- 视图环境之外的文本(toast、`NSAlert`、`NSMenu`、AppKit 面板、画布绘制)用 `AppLocalization.string(_:)`。 +- 新增或修改文案必须同步四个 `.lproj`,保持键与占位符(`%@` / `%d`)一致。 +- 提交前运行 `bash scripts/check-localization.sh`。 +- `Scene` 标题与 `.commands` 菜单项不在视图环境内,需 `.navigationTitle(Text(...))` 或 `AppLocalization`。 +- 持久化数据标识(如历史 `captureMode`)不本地化。详见 [ARCHITECTURE.md](./ARCHITECTURE.md#本地化)。 --- @@ -125,6 +140,7 @@ Platform Adapter (macOS: Vision, SCK, NSPasteboard) - [ ] CHANGELOG.md 已更新 - [ ] 对应 package 测试通过 - [ ] lint 通过(swift-format + swiftlint) +- [ ] 涉及界面文案:四语言键齐全且 `scripts/check-localization.sh` 通过 - [ ] 涉及权限:在 PR 描述中说明权限影响 ``` diff --git a/Docs/ARCHITECTURE.md b/Docs/ARCHITECTURE.md index baac43a..0f208e4 100644 --- a/Docs/ARCHITECTURE.md +++ b/Docs/ARCHITECTURE.md @@ -199,6 +199,41 @@ Struct (无状态工具) --- +## 本地化 + +界面语言由设置中的「应用语言」决定(`PreferenceKeys.appLanguage`,默认 `system`),支持 `en` / `zh-Hans` / `ja` / `ko`。文案以 `.lproj/Localizable.strings` 存储于 `App/SnapGlass/Resources/`。 + +Cocoa 存在两条互不相通的解析路径,两条都必须覆盖: + +| 路径 | 解析依据 | 适用场景 | +|------|----------|----------| +| SwiftUI `Text` / `LocalizedStringKey` | 视图环境 `\.locale` | 视图树内的文本,随应用语言即时切换 | +| `AppLocalization.string(_:)` | `AppLanguage.resourceIdentifier` 定位 `.lproj` | 视图环境之外的 Foundation 文本:toast、`NSAlert`、`NSMenu`、AppKit 面板、画布绘制 | + +`App.swift` 逐窗口注入 `.environment(\.locale, locale)`;`Window` 场景标题与 `.commands` 菜单项不在视图环境内,因此额外使用 `.navigationTitle(Text(...))` 与 `AppLocalization`。 + +禁止在界面代码中直接使用 `NSLocalizedString` / `String(localized:)`——它们跟随系统语言而非应用语言,会导致设置窗口内语言不一致。界面代码中的文本应为字符串字面量(`LocalizedStringKey`)或经由 `AppLocalization`;`scripts/check-localization.sh` 会校验四语言键一致、占位符一致、无冲突重复键,且源码引用的键均已存在。 + +历史记录的 `captureMode`(`area` / `window` / `fullscreen` / `scroll`)是持久化数据标识而非界面文案,不参与本地化,以保证 CSV 导出与存储语义稳定。 + +--- + +## 激活策略生命周期(accessory ↔ regular) + +`LSUIElement=true` 使应用默认以 `.accessory` 启动(无 Dock 图标、无应用菜单)。打开 Preferences / History / Editor / Permission 窗口时经 `AppWindowPresenter.present` 切到 `.regular` 以获得 Dock 图标与应用菜单;关闭最后一个窗口后须回退 `.accessory`。所有窗口打开路径均经 `present()`,因此降级门只依赖在途的 `pendingPresentations`。 + +关键约束(易回归点): + +| 约束 | 原因 | +|------|------| +| 降级门**不得**依赖 `registeredWindowIDs` | SwiftUI 关闭 `Window` 场景后仍保留其 `NSWindow`,`register()` 可能在 `willClose` 之后重登记,使集合永久非空而永久阻塞降级 | +| 降级时在 `setActivationPolicy(.accessory)` 成功后显式 `deactivate()` | 应用仍 active 时切换到 `.accessory` 常不能立即移除 Dock 图标(Apple 未文档化行为) | +| 重算触发面仅 `willClose` / `didBecomeKey` / `didMiniaturize` / `didDeminiaturize` | 不观察 `didOrderOffScreen` / `didHide` / `didResignActive`,避免隐藏、切 Space、全屏时误降级 | +| `willClose` 后延后一个 runloop tick(合并去抖)再重算 | `willClose` 触发时窗口仍 `isVisible`,需等 `orderOut` 完成 | +| `didBecomeKey` 补齐晋升 `.regular` | 与降级对称,避免应用卡在 accessory | + +--- + ## 技术选型 | 域 | 选择 | diff --git a/Packages/AnnotationCore/Sources/Renderer.swift b/Packages/AnnotationCore/Sources/Renderer.swift index fb4f761..15520a9 100644 --- a/Packages/AnnotationCore/Sources/Renderer.swift +++ b/Packages/AnnotationCore/Sources/Renderer.swift @@ -65,8 +65,20 @@ public struct Renderer: Sendable { let blurAndCropNodes = document.nodes.filter { $0.tool == .blur || $0.tool == .crop } let drawingNodes = document.nodes.filter { $0.tool != .blur && $0.tool != .crop } - renderNodes(blurAndCropNodes, in: context, imageSize: workingSize, styleScale: styleScale) - renderNodes(drawingNodes, in: context, imageSize: workingSize, styleScale: styleScale) + renderNodes( + blurAndCropNodes, + in: context, + imageSize: workingSize, + originalSize: imageSize, + styleScale: styleScale + ) + renderNodes( + drawingNodes, + in: context, + imageSize: workingSize, + originalSize: imageSize, + styleScale: styleScale + ) guard let result = context.makeImage() else { throw AnnotationError.renderFailed(reason: "CGContext.makeImage() 返回 nil") @@ -81,6 +93,7 @@ public struct Renderer: Sendable { _ nodes: [AnnotationNode], in context: CGContext, imageSize: CGSize, + originalSize: CGSize, styleScale: CGFloat ) { for node in nodes { @@ -89,6 +102,7 @@ public struct Renderer: Sendable { node, in: context, imageSize: imageSize, + originalSize: originalSize, styleScale: styleScale ) } @@ -100,8 +114,19 @@ public struct Renderer: Sendable { _ node: AnnotationNode, in context: CGContext, imageSize: CGSize, + originalSize: CGSize, styleScale: CGFloat ) { + if node.tool == .text { + // Layout in source pixels: scaling the font but not TextTool's padding + // can leave too little height for even one Core Text line in previews. + context.saveGState() + context.scaleBy(x: imageSize.width / originalSize.width, + y: imageSize.height / originalSize.height) + TextTool().render(node: node, in: context, imageSize: originalSize) + context.restoreGState() + return + } var scaledNode = node scaledNode.lineWidth = max(node.lineWidth * styleScale, 0.5) scaledNode.cornerRadius = node.cornerRadius * styleScale diff --git a/Packages/AnnotationCore/Sources/Tools/TextTool.swift b/Packages/AnnotationCore/Sources/Tools/TextTool.swift index b22cdda..4060e71 100644 --- a/Packages/AnnotationCore/Sources/Tools/TextTool.swift +++ b/Packages/AnnotationCore/Sources/Tools/TextTool.swift @@ -8,128 +8,144 @@ import SharedKit /// 使用 `points[0]` 作为文本起始位置(归一化坐标),`text` 属性为显示内容。 /// Text styling is stored directly on `AnnotationNode` so it remains editable. public struct TextTool: Sendable { - private let logger = Logger(category: "annotation.text") + private let logger = Logger(category: "annotation.text") - public init() {} + /// Creates a text annotation tool. + public init() {} - /// Returns the tight rendered size, including the tool's visual padding. - public func suggestedSize(for node: AnnotationNode) -> CGSize { - guard let text = node.text, !text.isEmpty else { return .zero } - let attributes = textAttributes(for: node) - let attributedString = NSAttributedString(string: text, attributes: attributes) - let framesetter = CTFramesetterCreateWithAttributedString(attributedString) - var fittedRange = CFRange() - let measured = CTFramesetterSuggestFrameSizeWithConstraints( - framesetter, - CFRange(location: 0, length: attributedString.length), - nil, - CGSize(width: 100_000, height: 100_000), - &fittedRange - ) + /// Returns the tight rendered size, including the tool's visual padding. + /// + /// - Parameters: + /// - node: The text annotation to measure. + /// - maximumWidth: An optional maximum width in image pixels. Text wraps when its + /// natural width exceeds this value. + /// - Returns: The measured text size in image pixels, including visual padding. + public func suggestedSize(for node: AnnotationNode, maximumWidth: CGFloat? = nil) -> CGSize { + guard let text = node.text, !text.isEmpty else { return .zero } + let attributes = textAttributes(for: node) + let attributedString = NSAttributedString(string: text, attributes: attributes) + let framesetter = CTFramesetterCreateWithAttributedString(attributedString) + let horizontalScale = max(node.textHorizontalScale, 0.1) + let horizontalPadding: CGFloat = 8 + let contentWidth = + maximumWidth.map { + max(($0 - horizontalPadding) / horizontalScale, 1) + } ?? 100_000 + var fittedRange = CFRange() + let measured = CTFramesetterSuggestFrameSizeWithConstraints( + framesetter, + CFRange(location: 0, length: attributedString.length), + nil, + CGSize(width: contentWidth, height: 100_000), + &fittedRange + ) - return CGSize( - width: ceil(measured.width * node.textHorizontalScale) + 8, - height: ceil(measured.height) + 8 - ) - } - - /// 在图形上下文中渲染文本标注。 - /// - /// - Parameters: - /// - node: 标注节点,须包含非空 `text` 和至少 1 个点 - /// - context: 目标绘图上下文 - /// - imageSize: 背景图片的像素尺寸 - public func render(node: AnnotationNode, in context: CGContext, imageSize: CGSize) { - guard let text = node.text, !text.isEmpty else { - logger.warning("文本工具需要非空文本内容") - return - } + let measuredSize = CGSize( + width: ceil(measured.width * horizontalScale) + horizontalPadding, + height: ceil(measured.height) + 8 + ) + guard let maximumWidth else { return measuredSize } + return CGSize(width: min(measuredSize.width, maximumWidth), height: measuredSize.height) + } - guard let point = node.points.first else { - logger.warning("文本工具需要至少 1 个点作为位置") - return - } + /// 在图形上下文中渲染文本标注。 + /// + /// - Parameters: + /// - node: 标注节点,须包含非空 `text` 和至少 1 个点 + /// - context: 目标绘图上下文 + /// - imageSize: 背景图片的像素尺寸 + public func render(node: AnnotationNode, in context: CGContext, imageSize: CGSize) { + guard let text = node.text, !text.isEmpty else { + logger.warning("文本工具需要非空文本内容") + return + } - let position = denormalize(point: point, to: imageSize) - let fontSize = node.fontSize + guard let point = node.points.first else { + logger.warning("文本工具需要至少 1 个点作为位置") + return + } - context.saveGState() - defer { context.restoreGState() } + let position = denormalize(point: point, to: imageSize) + let fontSize = node.fontSize - if let color = node.color { - context.setFillColor(color) - } - context.setAlpha(node.opacity) - context.setTextDrawingMode(.fill) + context.saveGState() + defer { context.restoreGState() } - let attributes = textAttributes(for: node) - let attributedString = NSAttributedString(string: text, attributes: attributes) - let framesetter = CTFramesetterCreateWithAttributedString(attributedString) - let suggestedSize = suggestedSize(for: node) - let textRect = node.normalizedRect == .zero - ? CGRect( - x: position.x, - y: position.y, - width: max(suggestedSize.width, fontSize * 2), - height: max(suggestedSize.height, fontSize * 1.3) - ) - : denormalize(rect: node.normalizedRect, to: imageSize) + if let color = node.color { + context.setFillColor(color) + } + context.setAlpha(node.opacity) + context.setTextDrawingMode(.fill) - if let fillColor = node.fillColor { - context.setFillColor(fillColor) - context.fill(textRect) - } + let attributes = textAttributes(for: node) + let attributedString = NSAttributedString(string: text, attributes: attributes) + let framesetter = CTFramesetterCreateWithAttributedString(attributedString) + let suggestedSize = suggestedSize(for: node, maximumWidth: max(imageSize.width - position.x, 1)) + let textRect = + node.normalizedRect == .zero + ? CGRect( + x: position.x, + y: position.y, + width: max(suggestedSize.width, fontSize * 2), + height: max(suggestedSize.height, fontSize * 1.3) + ) + : denormalize(rect: node.normalizedRect, to: imageSize) - let horizontalScale = max(node.textHorizontalScale, 0.1) - context.translateBy(x: textRect.minX, y: textRect.minY) - context.scaleBy(x: horizontalScale, y: 1) - let insetRect = CGRect( - x: 4 / horizontalScale, - y: 4, - width: max((textRect.width - 8) / horizontalScale, 1), - height: max(textRect.height - 8, 1) - ) - let frame = CTFramesetterCreateFrame( - framesetter, - CFRange(location: 0, length: attributedString.length), - CGPath(rect: insetRect, transform: nil), - nil - ) - CTFrameDraw(frame, context) + if let fillColor = node.fillColor { + context.setFillColor(fillColor) + context.fill(textRect) } - private func textAttributes(for node: AnnotationNode) -> [NSAttributedString.Key: Any] { - var alignment: CTTextAlignment = switch node.textAlignment { - case .leading: .left - case .center: .center - case .trailing: .right - } - let paragraphStyle = withUnsafePointer(to: &alignment) { pointer in - CTParagraphStyleCreate([ - CTParagraphStyleSetting( - spec: .alignment, - valueSize: MemoryLayout.size, - value: pointer - ), - ], 1) - } - return [ - .font: CTFontCreateWithName(node.fontName as CFString, node.fontSize, nil) as CTFont, - .foregroundColor: node.color ?? CGColor(gray: 1.0, alpha: 1.0), - NSAttributedString.Key(kCTParagraphStyleAttributeName as String): paragraphStyle, - ] - } + let horizontalScale = max(node.textHorizontalScale, 0.1) + context.translateBy(x: textRect.minX, y: textRect.minY) + context.scaleBy(x: horizontalScale, y: 1) + let insetRect = CGRect( + x: 4 / horizontalScale, + y: 4, + width: max((textRect.width - 8) / horizontalScale, 1), + height: max(textRect.height - 8, 1) + ) + let frame = CTFramesetterCreateFrame( + framesetter, + CFRange(location: 0, length: attributedString.length), + CGPath(rect: insetRect, transform: nil), + nil + ) + CTFrameDraw(frame, context) + } - private func denormalize(point: CGPoint, to size: CGSize) -> CGPoint { - CGPoint(x: point.x * size.width, y: point.y * size.height) + private func textAttributes(for node: AnnotationNode) -> [NSAttributedString.Key: Any] { + var alignment: CTTextAlignment = + switch node.textAlignment { + case .leading: .left + case .center: .center + case .trailing: .right + } + let paragraphStyle = withUnsafePointer(to: &alignment) { pointer in + let setting = CTParagraphStyleSetting( + spec: .alignment, + valueSize: MemoryLayout.size, + value: pointer + ) + return CTParagraphStyleCreate([setting], 1) } + return [ + .font: CTFontCreateWithName(node.fontName as CFString, node.fontSize, nil) as CTFont, + .foregroundColor: node.color ?? CGColor(gray: 1.0, alpha: 1.0), + NSAttributedString.Key(kCTParagraphStyleAttributeName as String): paragraphStyle, + ] + } - private func denormalize(rect: CGRect, to size: CGSize) -> CGRect { - CGRect( - x: rect.origin.x * size.width, - y: rect.origin.y * size.height, - width: rect.width * size.width, - height: rect.height * size.height - ) - } + private func denormalize(point: CGPoint, to size: CGSize) -> CGPoint { + CGPoint(x: point.x * size.width, y: point.y * size.height) + } + + private func denormalize(rect: CGRect, to size: CGSize) -> CGRect { + CGRect( + x: rect.origin.x * size.width, + y: rect.origin.y * size.height, + width: rect.width * size.width, + height: rect.height * size.height + ) + } } diff --git a/Packages/AnnotationCore/Tests/AnnotationCoreTests.swift b/Packages/AnnotationCore/Tests/AnnotationCoreTests.swift index 779a9e7..62f62f1 100644 --- a/Packages/AnnotationCore/Tests/AnnotationCoreTests.swift +++ b/Packages/AnnotationCore/Tests/AnnotationCoreTests.swift @@ -1,419 +1,448 @@ import CoreGraphics import Foundation import Testing + @testable import AnnotationCore struct AnnotationCoreTests { - @Test func cropCanBeUndoneAndRedone() throws { - let image = try makeImage(width: 200, height: 100) - var document = AnnotationDocument(baseImage: image) - document.addNode(AnnotationNode( - tool: .rect, - normalizedRect: CGRect(x: 0.1, y: 0.1, width: 0.2, height: 0.2) - )) - - let crop = AnnotationNode( - tool: .crop, - normalizedRect: CGRect(x: 0.25, y: 0.25, width: 0.5, height: 0.5) - ) - let interactor = AnnotationInteractor() - try interactor.apply(.crop, to: &document, node: crop) - - #expect(document.baseImage.width == 100) - #expect(document.baseImage.height == 50) - #expect(document.nodes.isEmpty) - - try document.undo() - #expect(document.baseImage.width == 200) - #expect(document.baseImage.height == 100) - #expect(document.nodes.count == 1) - - try document.redo() - #expect(document.baseImage.width == 100) - #expect(document.baseImage.height == 50) - #expect(document.nodes.isEmpty) - } - - @Test func cropConvertsBottomOriginToImageScanlines() throws { - let image = try makeVerticalSplitImage(width: 200, height: 100) - let crop = AnnotationNode( - tool: .crop, - normalizedRect: CGRect(x: 0, y: 0, width: 1, height: 0.5) - ) - - let result = try CropTool().crop(node: crop, from: image) - let pixel = try sampledPixel(from: result) - - #expect(result.width == 200) - #expect(result.height == 50) - #expect(pixel.blue > pixel.red) - } - - @Test func verticalEndpointCropKeepsFullWidth() throws { - let image = try makeImage(width: 300, height: 1_000) - let crop = AnnotationNode( - tool: .crop, - normalizedRect: CGRect(x: 0, y: 0.1, width: 1, height: 0.75) - ) - - let result = try CropTool().crop(node: crop, from: image) - - #expect(result.width == 300) - #expect(result.height == 750) - } - - @Test func longImagePolicyAlwaysEnablesScrollingCaptures() { - #expect(LongImageEditingPolicy.supportsVerticalTrim( - imageWidth: 1_920, - imageHeight: 1_080, - isScrollingCapture: true - )) - } - - @Test func longImagePolicyUsesTwoToOneAspectRatioForOtherCaptures() { - #expect(LongImageEditingPolicy.supportsVerticalTrim( - imageWidth: 1_000, - imageHeight: 2_000, - isScrollingCapture: false - )) - #expect(!LongImageEditingPolicy.supportsVerticalTrim( - imageWidth: 1_920, - imageHeight: 1_080, - isScrollingCapture: false + @Test func cropCanBeUndoneAndRedone() throws { + let image = try makeImage(width: 200, height: 100) + var document = AnnotationDocument(baseImage: image) + document.addNode( + AnnotationNode( + tool: .rect, + normalizedRect: CGRect(x: 0.1, y: 0.1, width: 0.2, height: 0.2) + )) + + let crop = AnnotationNode( + tool: .crop, + normalizedRect: CGRect(x: 0.25, y: 0.25, width: 0.5, height: 0.5) + ) + let interactor = AnnotationInteractor() + try interactor.apply(.crop, to: &document, node: crop) + + #expect(document.baseImage.width == 100) + #expect(document.baseImage.height == 50) + #expect(document.nodes.isEmpty) + + try document.undo() + #expect(document.baseImage.width == 200) + #expect(document.baseImage.height == 100) + #expect(document.nodes.count == 1) + + try document.redo() + #expect(document.baseImage.width == 100) + #expect(document.baseImage.height == 50) + #expect(document.nodes.isEmpty) + } + + @Test func cropConvertsBottomOriginToImageScanlines() throws { + let image = try makeVerticalSplitImage(width: 200, height: 100) + let crop = AnnotationNode( + tool: .crop, + normalizedRect: CGRect(x: 0, y: 0, width: 1, height: 0.5) + ) + + let result = try CropTool().crop(node: crop, from: image) + let pixel = try sampledPixel(from: result) + + #expect(result.width == 200) + #expect(result.height == 50) + #expect(pixel.blue > pixel.red) + } + + @Test func verticalEndpointCropKeepsFullWidth() throws { + let image = try makeImage(width: 300, height: 1_000) + let crop = AnnotationNode( + tool: .crop, + normalizedRect: CGRect(x: 0, y: 0.1, width: 1, height: 0.75) + ) + + let result = try CropTool().crop(node: crop, from: image) + + #expect(result.width == 300) + #expect(result.height == 750) + } + + @Test func longImagePolicyAlwaysEnablesScrollingCaptures() { + #expect( + LongImageEditingPolicy.supportsVerticalTrim( + imageWidth: 1_920, + imageHeight: 1_080, + isScrollingCapture: true + )) + } + + @Test func longImagePolicyUsesTwoToOneAspectRatioForOtherCaptures() { + #expect( + LongImageEditingPolicy.supportsVerticalTrim( + imageWidth: 1_000, + imageHeight: 2_000, + isScrollingCapture: false + )) + #expect( + !LongImageEditingPolicy.supportsVerticalTrim( + imageWidth: 1_920, + imageHeight: 1_080, + isScrollingCapture: false + )) + } + + @Test func exportPreservesOriginalResolution() throws { + let image = try makeImage(width: 5_000, height: 20) + let document = AnnotationDocument(baseImage: image) + + let rendered = try Renderer().render(document) + + #expect(rendered.width == 5_000) + #expect(rendered.height == 20) + } + + @Test func previewCanUseExplicitMaximumDimension() throws { + let image = try makeImage(width: 200, height: 100) + let document = AnnotationDocument(baseImage: image) + + let rendered = try Renderer().render(document, maximumDimension: 100) + + #expect(rendered.width == 100) + #expect(rendered.height == 50) + } + + @Test func nodeStyleCanBeUpdatedAndUndone() throws { + let image = try makeImage(width: 200, height: 100) + var document = AnnotationDocument(baseImage: image) + var node = AnnotationNode( + tool: .rect, + color: CGColor(red: 1, green: 0, blue: 0, alpha: 1), + normalizedRect: CGRect(x: 0.1, y: 0.1, width: 0.2, height: 0.2) + ) + document.addNode(node) + + node.strokeStyle = .dashed + node.cornerRadius = 12 + node.fillColor = CGColor(gray: 0, alpha: 0.5) + document.updateNode(node) + + #expect(document.nodes[0].strokeStyle == .dashed) + #expect(document.nodes[0].cornerRadius == 12) + #expect(document.nodes[0].fillColor != nil) + + try document.undo() + #expect(document.nodes[0].strokeStyle == .solid) + #expect(document.nodes[0].cornerRadius == 0) + #expect(document.nodes[0].fillColor == nil) + } + + @Test func nodeLayerMovementCanBeUndone() throws { + let image = try makeImage(width: 100, height: 100) + var document = AnnotationDocument(baseImage: image) + let first = AnnotationNode(tool: .rect, normalizedRect: CGRect(x: 0, y: 0, width: 0.2, height: 0.2)) + let second = AnnotationNode(tool: .rect, normalizedRect: CGRect(x: 0.2, y: 0.2, width: 0.2, height: 0.2)) + document.addNode(first) + document.addNode(second) + + document.moveNode(by: first.id, offset: 1) + #expect(document.nodes.map(\.id) == [second.id, first.id]) + + try document.undo() + #expect(document.nodes.map(\.id) == [first.id, second.id]) + } + + @Test func blurStyleCanBeUpdatedAndUndone() throws { + let image = try makeImage(width: 120, height: 80) + var document = AnnotationDocument(baseImage: image) + var node = AnnotationNode( + tool: .blur, + blurMode: .gaussian, + blurIntensity: 0.25, + normalizedRect: CGRect(x: 0.1, y: 0.2, width: 0.5, height: 0.4) + ) + document.addNode(node) + + node.blurMode = .mosaic + node.blurIntensity = 0.9 + document.updateNode(node) + + #expect(document.nodes[0].blurMode == .mosaic) + #expect(document.nodes[0].blurIntensity == 0.9) + + try document.undo() + #expect(document.nodes[0].blurMode == .gaussian) + #expect(document.nodes[0].blurIntensity == 0.25) + } + + @Test func blurIntensityIsClamped() { + let low = AnnotationNode(tool: .blur, blurIntensity: -1) + let high = AnnotationNode(tool: .blur, blurIntensity: 2) + + #expect(low.blurIntensity == 0) + #expect(high.blurIntensity == 1) + } + + @Test func everyBlurModeRendersAtOriginalResolution() throws { + let image = try makeCheckerboardImage(width: 96, height: 64) + + for mode in AnnotationBlurMode.allCases { + var document = AnnotationDocument(baseImage: image) + document.addNode( + AnnotationNode( + tool: .blur, + blurMode: mode, + blurIntensity: 0.7, + normalizedRect: CGRect(x: 0.2, y: 0.15, width: 0.6, height: 0.65) )) - } - - @Test func exportPreservesOriginalResolution() throws { - let image = try makeImage(width: 5_000, height: 20) - let document = AnnotationDocument(baseImage: image) - - let rendered = try Renderer().render(document) - - #expect(rendered.width == 5_000) - #expect(rendered.height == 20) - } - - @Test func previewCanUseExplicitMaximumDimension() throws { - let image = try makeImage(width: 200, height: 100) - let document = AnnotationDocument(baseImage: image) - - let rendered = try Renderer().render(document, maximumDimension: 100) - - #expect(rendered.width == 100) - #expect(rendered.height == 50) - } - - @Test func nodeStyleCanBeUpdatedAndUndone() throws { - let image = try makeImage(width: 200, height: 100) - var document = AnnotationDocument(baseImage: image) - var node = AnnotationNode( - tool: .rect, - color: CGColor(red: 1, green: 0, blue: 0, alpha: 1), - normalizedRect: CGRect(x: 0.1, y: 0.1, width: 0.2, height: 0.2) - ) - document.addNode(node) - - node.strokeStyle = .dashed - node.cornerRadius = 12 - node.fillColor = CGColor(gray: 0, alpha: 0.5) - document.updateNode(node) - - #expect(document.nodes[0].strokeStyle == .dashed) - #expect(document.nodes[0].cornerRadius == 12) - #expect(document.nodes[0].fillColor != nil) - - try document.undo() - #expect(document.nodes[0].strokeStyle == .solid) - #expect(document.nodes[0].cornerRadius == 0) - #expect(document.nodes[0].fillColor == nil) - } - - @Test func nodeLayerMovementCanBeUndone() throws { - let image = try makeImage(width: 100, height: 100) - var document = AnnotationDocument(baseImage: image) - let first = AnnotationNode(tool: .rect, normalizedRect: CGRect(x: 0, y: 0, width: 0.2, height: 0.2)) - let second = AnnotationNode(tool: .rect, normalizedRect: CGRect(x: 0.2, y: 0.2, width: 0.2, height: 0.2)) - document.addNode(first) - document.addNode(second) - - document.moveNode(by: first.id, offset: 1) - #expect(document.nodes.map(\.id) == [second.id, first.id]) - - try document.undo() - #expect(document.nodes.map(\.id) == [first.id, second.id]) - } - @Test func blurStyleCanBeUpdatedAndUndone() throws { - let image = try makeImage(width: 120, height: 80) - var document = AnnotationDocument(baseImage: image) - var node = AnnotationNode( - tool: .blur, - blurMode: .gaussian, - blurIntensity: 0.25, - normalizedRect: CGRect(x: 0.1, y: 0.2, width: 0.5, height: 0.4) - ) - document.addNode(node) - - node.blurMode = .mosaic - node.blurIntensity = 0.9 - document.updateNode(node) - - #expect(document.nodes[0].blurMode == .mosaic) - #expect(document.nodes[0].blurIntensity == 0.9) - - try document.undo() - #expect(document.nodes[0].blurMode == .gaussian) - #expect(document.nodes[0].blurIntensity == 0.25) + let rendered = try Renderer().render(document) + #expect(rendered.width == image.width) + #expect(rendered.height == image.height) } - - @Test func blurIntensityIsClamped() { - let low = AnnotationNode(tool: .blur, blurIntensity: -1) - let high = AnnotationNode(tool: .blur, blurIntensity: 2) - - #expect(low.blurIntensity == 0) - #expect(high.blurIntensity == 1) + } + + @Test func textHorizontalScaleIsClamped() { + let compressed = AnnotationNode(tool: .text, textHorizontalScale: 0) + let stretched = AnnotationNode(tool: .text, textHorizontalScale: 20) + + #expect(compressed.textHorizontalScale == 0.1) + #expect(stretched.textHorizontalScale == 10) + } + + @Test func textSuggestedSizeTracksFontAndHorizontalScale() { + let tool = TextTool() + let regular = AnnotationNode(tool: .text, text: "SnapGlass", fontSize: 24) + let stretched = AnnotationNode( + tool: .text, + text: "SnapGlass", + fontSize: 24, + textHorizontalScale: 2 + ) + let larger = AnnotationNode(tool: .text, text: "SnapGlass", fontSize: 48) + + let regularSize = tool.suggestedSize(for: regular) + let stretchedSize = tool.suggestedSize(for: stretched) + let largerSize = tool.suggestedSize(for: larger) + + #expect(stretchedSize.width - 8 > (regularSize.width - 8) * 1.9) + #expect(abs(stretchedSize.height - regularSize.height) < 0.01) + #expect(largerSize.width - 8 > (regularSize.width - 8) * 1.9) + #expect(largerSize.height - 8 > (regularSize.height - 8) * 1.9) + } + + @Test func textSuggestedSizeWrapsWithinMaximumWidth() { + let tool = TextTool() + let node = AnnotationNode( + tool: .text, + text: Array(repeating: "wrapping", count: 12).joined(separator: " "), + fontSize: 24 + ) + + let naturalSize = tool.suggestedSize(for: node) + let constrainedSize = tool.suggestedSize(for: node, maximumWidth: 160) + + #expect(constrainedSize.width <= 160) + #expect(constrainedSize.height > naturalSize.height * 2) + } + + @Test func textScaleUpdateCanBeUndoneAndRedone() throws { + let image = try makeImage(width: 320, height: 180) + var document = AnnotationDocument(baseImage: image) + var node = AnnotationNode( + tool: .text, + text: "Resizable", + fontSize: 20, + normalizedRect: CGRect(x: 0.1, y: 0.1, width: 0.3, height: 0.2) + ) + document.addNode(node) + + node.fontSize = 36 + node.textHorizontalScale = 1.75 + node.normalizedRect = CGRect(x: 0.1, y: 0.1, width: 0.5, height: 0.3) + document.updateNode(node) + + #expect(document.nodes[0].fontSize == 36) + #expect(document.nodes[0].textHorizontalScale == 1.75) + + try document.undo() + #expect(document.nodes[0].fontSize == 20) + #expect(document.nodes[0].textHorizontalScale == 1) + + try document.redo() + #expect(document.nodes[0].fontSize == 36) + #expect(document.nodes[0].textHorizontalScale == 1.75) + } + + @Test func horizontallyScaledTextRendersAtOriginalResolution() throws { + let image = try makeImage(width: 320, height: 180) + var document = AnnotationDocument(baseImage: image) + document.addNode( + AnnotationNode( + tool: .text, + text: "Scaled text", + fontSize: 32, + textHorizontalScale: 2.25, + normalizedRect: CGRect(x: 0.1, y: 0.2, width: 0.8, height: 0.3) + )) + + let rendered = try Renderer().render(document) + + #expect(rendered.width == image.width) + #expect(rendered.height == image.height) + } + + @Test func textIsVisibleInsideTightSuggestedBounds() throws { + let image = try makeImage(width: 320, height: 180) + let tool = TextTool() + var node = AnnotationNode( + tool: .text, + color: CGColor(red: 1, green: 0, blue: 0, alpha: 1), + points: [CGPoint(x: 0.1, y: 0.2)], + text: "Visible text", + fontSize: 32 + ) + let suggestedSize = tool.suggestedSize(for: node) + node.normalizedRect = CGRect( + x: 0.1, + y: 0.2, + width: suggestedSize.width / CGFloat(image.width), + height: suggestedSize.height / CGFloat(image.height) + ) + + var document = AnnotationDocument(baseImage: image) + document.addNode(node) + let rendered = try Renderer().render(document) + let bytes = rendered.dataProvider?.data.flatMap { Data($0 as Data) } ?? Data() + + #expect(bytes.contains { $0 < 240 }) + } + + @Test func smallTextRemainsVisibleInsideTightBounds() throws { + let image = try makeImage(width: 320, height: 180) + let tool = TextTool() + var node = AnnotationNode( + tool: .text, + color: CGColor(red: 0, green: 0, blue: 0, alpha: 1), + points: [CGPoint(x: 0.1, y: 0.2)], + text: "Small", + fontSize: 10 + ) + let suggestedSize = tool.suggestedSize(for: node) + node.normalizedRect = CGRect( + x: 0.1, + y: 0.2, + width: suggestedSize.width / CGFloat(image.width), + height: suggestedSize.height / CGFloat(image.height) + ) + + var document = AnnotationDocument(baseImage: image) + document.addNode(node) + let rendered = try Renderer().render(document) + let bytes = rendered.dataProvider?.data.flatMap { Data($0 as Data) } ?? Data() + + #expect(bytes.contains { $0 < 240 }) + } + + private func makeImage(width: Int, height: Int) throws -> CGImage { + guard + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + throw AnnotationError.renderFailed(reason: "Unable to create test context") } - - @Test func everyBlurModeRendersAtOriginalResolution() throws { - let image = try makeCheckerboardImage(width: 96, height: 64) - - for mode in AnnotationBlurMode.allCases { - var document = AnnotationDocument(baseImage: image) - document.addNode(AnnotationNode( - tool: .blur, - blurMode: mode, - blurIntensity: 0.7, - normalizedRect: CGRect(x: 0.2, y: 0.15, width: 0.6, height: 0.65) - )) - - let rendered = try Renderer().render(document) - #expect(rendered.width == image.width) - #expect(rendered.height == image.height) - } + context.setFillColor(CGColor(gray: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + guard let image = context.makeImage() else { + throw AnnotationError.renderFailed(reason: "Unable to create test image") } - - @Test func textHorizontalScaleIsClamped() { - let compressed = AnnotationNode(tool: .text, textHorizontalScale: 0) - let stretched = AnnotationNode(tool: .text, textHorizontalScale: 20) - - #expect(compressed.textHorizontalScale == 0.1) - #expect(stretched.textHorizontalScale == 10) + return image + } + + private func makeCheckerboardImage(width: Int, height: Int) throws -> CGImage { + guard + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + throw AnnotationError.renderFailed(reason: "Unable to create checkerboard context") } - @Test func textSuggestedSizeTracksFontAndHorizontalScale() { - let tool = TextTool() - let regular = AnnotationNode(tool: .text, text: "SnapGlass", fontSize: 24) - let stretched = AnnotationNode( - tool: .text, - text: "SnapGlass", - fontSize: 24, - textHorizontalScale: 2 - ) - let larger = AnnotationNode(tool: .text, text: "SnapGlass", fontSize: 48) - - let regularSize = tool.suggestedSize(for: regular) - let stretchedSize = tool.suggestedSize(for: stretched) - let largerSize = tool.suggestedSize(for: larger) - - #expect(stretchedSize.width - 8 > (regularSize.width - 8) * 1.9) - #expect(abs(stretchedSize.height - regularSize.height) < 0.01) - #expect(largerSize.width - 8 > (regularSize.width - 8) * 1.9) - #expect(largerSize.height - 8 > (regularSize.height - 8) * 1.9) + let cell = 8 + for row in stride(from: 0, to: height, by: cell) { + for column in stride(from: 0, to: width, by: cell) { + let isLight = ((column / cell) + (row / cell)).isMultiple(of: 2) + context.setFillColor(CGColor(gray: isLight ? 0.9 : 0.1, alpha: 1)) + context.fill(CGRect(x: column, y: row, width: cell, height: cell)) + } } - - @Test func textScaleUpdateCanBeUndoneAndRedone() throws { - let image = try makeImage(width: 320, height: 180) - var document = AnnotationDocument(baseImage: image) - var node = AnnotationNode( - tool: .text, - text: "Resizable", - fontSize: 20, - normalizedRect: CGRect(x: 0.1, y: 0.1, width: 0.3, height: 0.2) - ) - document.addNode(node) - - node.fontSize = 36 - node.textHorizontalScale = 1.75 - node.normalizedRect = CGRect(x: 0.1, y: 0.1, width: 0.5, height: 0.3) - document.updateNode(node) - - #expect(document.nodes[0].fontSize == 36) - #expect(document.nodes[0].textHorizontalScale == 1.75) - - try document.undo() - #expect(document.nodes[0].fontSize == 20) - #expect(document.nodes[0].textHorizontalScale == 1) - - try document.redo() - #expect(document.nodes[0].fontSize == 36) - #expect(document.nodes[0].textHorizontalScale == 1.75) + guard let image = context.makeImage() else { + throw AnnotationError.renderFailed(reason: "Unable to create checkerboard image") } - - @Test func horizontallyScaledTextRendersAtOriginalResolution() throws { - let image = try makeImage(width: 320, height: 180) - var document = AnnotationDocument(baseImage: image) - document.addNode(AnnotationNode( - tool: .text, - text: "Scaled text", - fontSize: 32, - textHorizontalScale: 2.25, - normalizedRect: CGRect(x: 0.1, y: 0.2, width: 0.8, height: 0.3) - )) - - let rendered = try Renderer().render(document) - - #expect(rendered.width == image.width) - #expect(rendered.height == image.height) + return image + } + + private func makeVerticalSplitImage(width: Int, height: Int) throws -> CGImage { + guard + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo.byteOrder32Big.rawValue + | CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + throw AnnotationError.renderFailed(reason: "Unable to create split image context") } - - @Test func textIsVisibleInsideTightSuggestedBounds() throws { - let image = try makeImage(width: 320, height: 180) - let tool = TextTool() - var node = AnnotationNode( - tool: .text, - color: CGColor(red: 1, green: 0, blue: 0, alpha: 1), - points: [CGPoint(x: 0.1, y: 0.2)], - text: "Visible text", - fontSize: 32 - ) - let suggestedSize = tool.suggestedSize(for: node) - node.normalizedRect = CGRect( - x: 0.1, - y: 0.2, - width: suggestedSize.width / CGFloat(image.width), - height: suggestedSize.height / CGFloat(image.height) - ) - - var document = AnnotationDocument(baseImage: image) - document.addNode(node) - let rendered = try Renderer().render(document) - let bytes = rendered.dataProvider?.data.flatMap { Data($0 as Data) } ?? Data() - - #expect(bytes.contains { $0 < 240 }) + context.setFillColor(CGColor(red: 0, green: 0, blue: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height / 2)) + context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1)) + context.fill(CGRect(x: 0, y: height / 2, width: width, height: height / 2)) + guard let image = context.makeImage() else { + throw AnnotationError.renderFailed(reason: "Unable to create split image") } - - @Test func smallTextRemainsVisibleInsideTightBounds() throws { - let image = try makeImage(width: 320, height: 180) - let tool = TextTool() - var node = AnnotationNode( - tool: .text, - color: CGColor(red: 0, green: 0, blue: 0, alpha: 1), - points: [CGPoint(x: 0.1, y: 0.2)], - text: "Small", - fontSize: 10 + return image + } + + private func sampledPixel(from image: CGImage) throws -> SampledPixel { + var bytes = [UInt8](repeating: 0, count: 4) + return try bytes.withUnsafeMutableBytes { buffer in + guard let address = buffer.baseAddress, + let context = CGContext( + data: address, + width: 1, + height: 1, + bitsPerComponent: 8, + bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGBitmapInfo.byteOrder32Big.rawValue + | CGImageAlphaInfo.premultipliedLast.rawValue ) - let suggestedSize = tool.suggestedSize(for: node) - node.normalizedRect = CGRect( - x: 0.1, - y: 0.2, - width: suggestedSize.width / CGFloat(image.width), - height: suggestedSize.height / CGFloat(image.height) - ) - - var document = AnnotationDocument(baseImage: image) - document.addNode(node) - let rendered = try Renderer().render(document) - let bytes = rendered.dataProvider?.data.flatMap { Data($0 as Data) } ?? Data() - - #expect(bytes.contains { $0 < 240 }) - } - - private func makeImage(width: Int, height: Int) throws -> CGImage { - guard let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: 0, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - throw AnnotationError.renderFailed(reason: "Unable to create test context") - } - context.setFillColor(CGColor(gray: 1, alpha: 1)) - context.fill(CGRect(x: 0, y: 0, width: width, height: height)) - guard let image = context.makeImage() else { - throw AnnotationError.renderFailed(reason: "Unable to create test image") - } - return image - } - - private func makeCheckerboardImage(width: Int, height: Int) throws -> CGImage { - guard let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: 0, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - throw AnnotationError.renderFailed(reason: "Unable to create checkerboard context") - } - - let cell = 8 - for row in stride(from: 0, to: height, by: cell) { - for column in stride(from: 0, to: width, by: cell) { - let isLight = ((column / cell) + (row / cell)).isMultiple(of: 2) - context.setFillColor(CGColor(gray: isLight ? 0.9 : 0.1, alpha: 1)) - context.fill(CGRect(x: column, y: row, width: cell, height: cell)) - } - } - guard let image = context.makeImage() else { - throw AnnotationError.renderFailed(reason: "Unable to create checkerboard image") - } - return image - } - - private func makeVerticalSplitImage(width: Int, height: Int) throws -> CGImage { - guard let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: 0, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGBitmapInfo.byteOrder32Big.rawValue - | CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - throw AnnotationError.renderFailed(reason: "Unable to create split image context") - } - context.setFillColor(CGColor(red: 0, green: 0, blue: 1, alpha: 1)) - context.fill(CGRect(x: 0, y: 0, width: width, height: height / 2)) - context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1)) - context.fill(CGRect(x: 0, y: height / 2, width: width, height: height / 2)) - guard let image = context.makeImage() else { - throw AnnotationError.renderFailed(reason: "Unable to create split image") - } - return image - } - - private func sampledPixel(from image: CGImage) throws -> SampledPixel { - var bytes = [UInt8](repeating: 0, count: 4) - return try bytes.withUnsafeMutableBytes { buffer in - guard let address = buffer.baseAddress, - let context = CGContext( - data: address, - width: 1, - height: 1, - bitsPerComponent: 8, - bytesPerRow: 4, - space: CGColorSpaceCreateDeviceRGB(), - bitmapInfo: CGBitmapInfo.byteOrder32Big.rawValue - | CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - throw AnnotationError.renderFailed(reason: "Unable to create pixel sampling context") - } - context.interpolationQuality = .none - context.draw(image, in: CGRect(x: 0, y: 0, width: 1, height: 1)) - let pixels = address.assumingMemoryBound(to: UInt8.self) - return SampledPixel(red: pixels[0], green: pixels[1], blue: pixels[2]) - } + else { + throw AnnotationError.renderFailed(reason: "Unable to create pixel sampling context") + } + context.interpolationQuality = .none + context.draw(image, in: CGRect(x: 0, y: 0, width: 1, height: 1)) + let pixels = address.assumingMemoryBound(to: UInt8.self) + return SampledPixel(red: pixels[0], green: pixels[1], blue: pixels[2]) } + } } private struct SampledPixel { - let red: UInt8 - let green: UInt8 - let blue: UInt8 + let red: UInt8 + let green: UInt8 + let blue: UInt8 } diff --git a/Packages/AnnotationCore/Tests/TextPreviewRegressionTests.swift b/Packages/AnnotationCore/Tests/TextPreviewRegressionTests.swift new file mode 100644 index 0000000..7a85ba7 --- /dev/null +++ b/Packages/AnnotationCore/Tests/TextPreviewRegressionTests.swift @@ -0,0 +1,26 @@ +import CoreGraphics +import Foundation +import Testing +@testable import AnnotationCore + +struct TextPreviewRegressionTests { + @Test func tightTextRemainsVisibleWhenPreviewIsReduced() throws { + let context = try #require(CGContext(data: nil, width: 2000, height: 1000, + bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + let base = try #require(context.makeImage()) + var node = AnnotationNode(tool: .text, color: CGColor(gray: 1, alpha: 1), + points: [CGPoint(x: 0.1, y: 0.1)], text: "Visible text", fontSize: 24) + let size = TextTool().suggestedSize(for: node) + node.normalizedRect = CGRect(x: 0.1, y: 0.1, width: size.width / 2000, height: size.height / 1000) + var document = AnnotationDocument(baseImage: base) + document.addNode(node) + for dimension: CGFloat in [500, 800, 1200, 2000] { + let rendered = try Renderer().render(document, maximumDimension: dimension) + let bytes = try #require(rendered.dataProvider?.data) + let pointer = try #require(CFDataGetBytePtr(bytes)) + let painted = (0.. 0 }.count + #expect(painted > 20, "Text disappeared at preview size \(dimension)") + } + } +} diff --git a/Packages/HistoryCore/Sources/HistoryActor+DiskIO.swift b/Packages/HistoryCore/Sources/HistoryActor+DiskIO.swift index 029600a..224d13e 100644 --- a/Packages/HistoryCore/Sources/HistoryActor+DiskIO.swift +++ b/Packages/HistoryCore/Sources/HistoryActor+DiskIO.swift @@ -54,7 +54,10 @@ extension HistoryActor { } let transactionDir = tempDir.appendingPathComponent("strip-\(UUID().uuidString)") - let staged = try stageFile(for: category, id: id, in: transactionDir) + try transactionDir.ensureDirectoryExists() + let staged = try stageMediaFiles( + for: id, thumbnail: category == .thumbnail, in: transactionDir + ) switch category { case .image: @@ -72,11 +75,11 @@ extension HistoryActor { } invalidateDiskCache() } catch { - rollbackStagedFile(staged) + for item in staged.reversed() { rollbackStagedFile(item) } throw error } - if staged != nil { + if !staged.isEmpty { do { try FileManager.default.removeItem(at: transactionDir) } catch { @@ -85,6 +88,27 @@ extension HistoryActor { } } + /// Moves all media files of the given kind into the transaction directory, + /// rolling back what was moved so far on failure. + private func stageMediaFiles( + for id: UUID, + thumbnail: Bool, + in transactionDir: URL + ) throws -> [(sourceURL: URL, stagedURL: URL)] { + var staged: [(sourceURL: URL, stagedURL: URL)] = [] + do { + for file in try mediaFiles(for: id, thumbnail: thumbnail) { + let destination = transactionDir.appendingPathComponent(file.lastPathComponent) + try FileManager.default.moveItem(at: file, to: destination) + staged.append((file, destination)) + } + } catch { + for item in staged.reversed() { rollbackStagedFile(item) } + throw error + } + return staged + } + /// 将暂存文件恢复到原位置。 func rollbackStagedFile(_ staged: (sourceURL: URL, stagedURL: URL)?) { guard let staged else { return } @@ -129,7 +153,7 @@ extension HistoryActor { } func storedSize(for id: UUID) -> UInt64 { - let files = [entryFileURL(for: id), imageFileURL(for: id), thumbnailFileURL(for: id)] + let files = [entryFileURL(for: id)] + ((try? mediaFiles(for: id)) ?? []) return files.reduce(into: 0) { total, file in guard let values = try? file.resourceValues(forKeys: [.fileSizeKey]), let size = values.fileSize, @@ -146,12 +170,14 @@ extension HistoryActor { /// 获取加密图片文件路径 func imageFileURL(for id: UUID) -> URL { - imagesDir.appendingPathComponent("\(id.uuidString).enc") + let revision = (entries[id] ?? diskCache[id] ?? loadEntryFromDiskSync(id: id))?.imageRevision + return mediaURL(for: id, revision: revision, thumbnail: false) } /// 获取缩略图文件路径 func thumbnailFileURL(for id: UUID) -> URL { - thumbsDir.appendingPathComponent("\(id.uuidString).png") + let revision = (entries[id] ?? diskCache[id] ?? loadEntryFromDiskSync(id: id))?.imageRevision + return mediaURL(for: id, revision: revision, thumbnail: true) } /// 同步加载所有磁盘条目到内存 diff --git a/Packages/HistoryCore/Sources/HistoryActor+Editing.swift b/Packages/HistoryCore/Sources/HistoryActor+Editing.swift new file mode 100644 index 0000000..89e8390 --- /dev/null +++ b/Packages/HistoryCore/Sources/HistoryActor+Editing.swift @@ -0,0 +1,127 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +extension HistoryActor { + public static let imagesDidChange = Notification.Name("SnapGlass.historyImagesDidChange") + + /// Write new immutable media first, then atomically switch the encrypted + /// metadata reference. Any failure leaves the old image and original intact. + public func replaceImage(id: UUID, image: CGImage) throws { + guard var entry = entries[id] ?? diskCache[id] ?? loadEntryFromDiskSync(id: id), + entry.imagePath != nil else { throw HistoryError.entryNotFound(id: id) } + let originalURL = mediaURL(for: id, revision: nil, thumbnail: false) + guard FileManager.default.fileExists(atPath: originalURL.path) else { + throw HistoryError.fileIOError(path: originalURL.path) + } + // Verify the recovery copy before offering a reversible replacement. + _ = try cryptoService.decrypt(Data(contentsOf: originalURL)) + let revision = UUID() + let imageURL = mediaURL(for: id, revision: revision, thumbnail: false) + let thumbURL = mediaURL(for: id, revision: revision, thumbnail: true) + do { + let data = try Self.pngData(image) + try cryptoService.encrypt(data).write(to: imageURL, options: .atomic) + let thumb = try Self.thumbnail(from: data) + try thumb.write(to: thumbURL, options: .atomic) + entry.imageRevision = revision + entry.imagePath = imageURL + entry.thumbnailPath = thumbURL + entry.textContent = "" + entry.ocrConfidence = 0 + try persistEntry(entry) + } catch { + for url in [imageURL, thumbURL] { + do { + try removeIfExists(url) + } catch { + logger.warning("Unable to remove uncommitted edit: \(url.lastPathComponent)") + } + } + throw error + } + entries[id] = entry + diskCache[id] = entry + removeObsoleteEdits(for: entry) + NotificationCenter.default.post(name: Self.imagesDidChange, object: nil) + } + + public func restoreOriginal(id: UUID) throws { + guard var entry = entries[id] ?? diskCache[id] ?? loadEntryFromDiskSync(id: id) else { + throw HistoryError.entryNotFound(id: id) + } + guard entry.canRestoreOriginal else { return } + let originalURL = mediaURL(for: id, revision: nil, thumbnail: false) + let data = try cryptoService.decrypt(Data(contentsOf: originalURL)) + let thumbnailURL = mediaURL(for: id, revision: nil, thumbnail: true) + try Self.thumbnail(from: data).write(to: thumbnailURL, options: .atomic) + entry.imageRevision = nil + entry.imagePath = originalURL + entry.thumbnailPath = thumbnailURL + entry.textContent = "" + entry.ocrConfidence = 0 + try persistEntry(entry) + entries[id] = entry + diskCache[id] = entry + removeObsoleteEdits(for: entry) + NotificationCenter.default.post(name: Self.imagesDidChange, object: nil) + } + + func mediaURL(for id: UUID, revision: UUID?, thumbnail: Bool) -> URL { + let name = id.uuidString + (revision.map { "-\($0.uuidString)" } ?? "") + return (thumbnail ? thumbsDir : imagesDir) + .appendingPathComponent(name + (thumbnail ? ".png" : ".enc")) + } + + func mediaFiles(for id: UUID, thumbnail: Bool? = nil) throws -> [URL] { + let directories = thumbnail.map { [$0 ? thumbsDir : imagesDir] } ?? [imagesDir, thumbsDir] + return try directories.flatMap { directory in + try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + .filter { file in + let stem = file.deletingPathExtension().lastPathComponent + return stem == id.uuidString || (stem.hasPrefix(id.uuidString + "-") + && UUID(uuidString: String(stem.dropFirst(37))) != nil) + } + } + } + + private func removeObsoleteEdits(for entry: HistoryEntry) { + do { + let keep = Set([ + mediaURL(for: entry.id, revision: nil, thumbnail: false), + mediaURL(for: entry.id, revision: nil, thumbnail: true), + imageFileURL(for: entry.id), thumbnailFileURL(for: entry.id), + ].map { $0.standardizedFileURL.path }) + for file in try mediaFiles(for: entry.id) where !keep.contains(file.standardizedFileURL.path) { + try removeIfExists(file) + } + } catch { + logger.warning("Edit committed; obsolete media cleanup deferred for \(entry.id)") + } + } + + private static func pngData(_ image: CGImage) throws -> Data { + let data = NSMutableData() + guard let destination = CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil) else { + throw HistoryError.fileIOError(path: "PNG encoding") + } + CGImageDestinationAddImage(destination, image, nil) + guard CGImageDestinationFinalize(destination) else { + throw HistoryError.fileIOError(path: "PNG encoding") + } + return data as Data + } + + private static func thumbnail(from data: Data) throws -> Data { + let options: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 200, + ] + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) + else { throw HistoryError.fileIOError(path: "Thumbnail encoding") } + return try pngData(image) + } +} diff --git a/Packages/HistoryCore/Sources/HistoryActor+HistoryProtocol.swift b/Packages/HistoryCore/Sources/HistoryActor+HistoryProtocol.swift index 60fb510..719171b 100644 --- a/Packages/HistoryCore/Sources/HistoryActor+HistoryProtocol.swift +++ b/Packages/HistoryCore/Sources/HistoryActor+HistoryProtocol.swift @@ -136,6 +136,7 @@ extension HistoryActor { /// - sourceType: Source type for the history entry. /// - sourceAppName: Optional source application name. /// - sourceWindowTitle: Optional source window title. + @discardableResult public func saveCapture( image: CGImage, textContent: String, @@ -144,7 +145,7 @@ extension HistoryActor { sourceType: HistorySourceType = .screenshot, sourceAppName: String? = nil, sourceWindowTitle: String? = nil - ) async throws { + ) async throws -> UUID { try tempDir.ensureDirectoryExists() let tempID = UUID() @@ -180,6 +181,7 @@ extension HistoryActor { ) try await save(entry) + return entry.id } public func load(id: UUID) async throws -> HistoryEntry? { @@ -217,11 +219,7 @@ extension HistoryActor { public func delete(id: UUID) async throws { let transactionDir = tempDir.appendingPathComponent("delete-\(UUID().uuidString)") try transactionDir.ensureDirectoryExists() - let files = [ - entryFileURL(for: id), - imageFileURL(for: id), - thumbnailFileURL(for: id), - ] + let files = [entryFileURL(for: id)] + (try mediaFiles(for: id)) var staged: [(original: URL, staged: URL)] = [] do { diff --git a/Packages/HistoryCore/Sources/HistoryEntry.swift b/Packages/HistoryCore/Sources/HistoryEntry.swift index d5282d9..ab82b22 100644 --- a/Packages/HistoryCore/Sources/HistoryEntry.swift +++ b/Packages/HistoryCore/Sources/HistoryEntry.swift @@ -31,7 +31,7 @@ public struct HistoryEntry: Sendable, Identifiable, Codable { /// OCR 置信度 (0.0–1.0) /// /// 低于 0.7 时引擎会展示降级提示。 - public let ocrConfidence: Float + public var ocrConfidence: Float /// 截图模式 /// @@ -74,6 +74,11 @@ public struct HistoryEntry: Sendable, Identifiable, Codable { /// 支持多标签分类,标签名不区分大小写。 public var tags: [String] + /// Optional so existing v2 records remain readable. The canonical image is + /// never overwritten; this points to an immutable edited image/thumbnail pair. + public var imageRevision: UUID? + public var canRestoreOriginal: Bool { imageRevision != nil && imagePath != nil } + // MARK: - Initialization /// 创建历史记录条目 @@ -122,5 +127,6 @@ public struct HistoryEntry: Sendable, Identifiable, Codable { case id, timestamp, textContent, ocrConfidence, captureMode case sourceType, sourceAppName, sourceWindowTitle case imagePath, thumbnailPath, isFavourite, tags + case imageRevision } } diff --git a/Packages/HistoryCore/Tests/HistoryCoreTests/EditedHistoryTests.swift b/Packages/HistoryCore/Tests/HistoryCoreTests/EditedHistoryTests.swift new file mode 100644 index 0000000..46e65c9 --- /dev/null +++ b/Packages/HistoryCore/Tests/HistoryCoreTests/EditedHistoryTests.swift @@ -0,0 +1,75 @@ +import CoreGraphics +import Foundation +import Testing +@testable import HistoryCore + +struct EditedHistoryTests { + @Test func repeatedReplacementCanRestoreOriginalAfterRestart() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let history = try HistoryActor(baseURL: root) + let id = try await history.saveCapture(image: image(100), textContent: "old OCR", + ocrConfidence: 1, captureMode: "area") + let original = try await history.imageData(for: id) + _ = try await history.setFavourite(id: id, isFavourite: true) + try await history.replaceImage(id: id, image: image(200)) + try await history.replaceImage(id: id, image: image(300)) + let reloaded = try HistoryActor(baseURL: root) + let edited = try #require(try await reloaded.load(id: id)) + #expect(edited.canRestoreOriginal) + #expect(edited.textContent.isEmpty) + #expect(edited.isFavourite) + #expect(await reloaded.count() == 1) + try await reloaded.restoreOriginal(id: id) + #expect(try await reloaded.imageData(for: id) == original) + #expect(try await reloaded.load(id: id)?.canRestoreOriginal == false) + let restarted = try HistoryActor(baseURL: root) + #expect(try await restarted.imageData(for: id) == original) + try await restarted.delete(id: id) + let files = try FileManager.default.contentsOfDirectory( + at: root.appendingPathComponent("History/v2/images"), includingPropertiesForKeys: nil) + #expect(files.isEmpty) + } + + @Test func failedReplacementLeavesOriginalUntouched() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let history = try HistoryActor(baseURL: root) + let id = try await history.saveCapture(image: image(100), textContent: "old OCR", + ocrConfidence: 1, captureMode: "area") + let original = try await history.imageData(for: id) + // Prevent the metadata commit after new media files have been written. + let entryURL = root.appendingPathComponent("History/v2/entries/\(id.uuidString).enc") + let metadata = try Data(contentsOf: entryURL) + try FileManager.default.removeItem(at: entryURL) + try FileManager.default.createDirectory(at: entryURL, withIntermediateDirectories: false) + await #expect(throws: (any Error).self) { + try await history.replaceImage(id: id, image: image(200)) + } + #expect(try await history.imageData(for: id) == original) + try FileManager.default.removeItem(at: entryURL) + try metadata.write(to: entryURL) + let reloaded = try HistoryActor(baseURL: root) + #expect(try await reloaded.imageData(for: id) == original) + #expect(try await reloaded.load(id: id)?.canRestoreOriginal == false) + } + + @Test func missingSourceCannotBeOverwritten() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let history = try HistoryActor(baseURL: root) + await #expect(throws: (any Error).self) { + try await history.replaceImage(id: UUID(), image: image(100)) + } + #expect(await history.count() == 0) + } + + private func image(_ width: Int) throws -> CGImage { + let context = try #require(CGContext(data: nil, width: width, height: 80, + bitsPerComponent: 8, bytesPerRow: 0, space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)) + context.setFillColor(CGColor(gray: 0.5, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: 80)) + return try #require(context.makeImage()) + } +} diff --git a/Packages/SharedKit/Sources/PreferenceKeys.swift b/Packages/SharedKit/Sources/PreferenceKeys.swift index 3349f72..9d3083e 100644 --- a/Packages/SharedKit/Sources/PreferenceKeys.swift +++ b/Packages/SharedKit/Sources/PreferenceKeys.swift @@ -14,6 +14,7 @@ public enum PreferenceKeys { public static let captureAutoOCR = "capture_autoOCR" public static let captureCopyOCRText = "capture_copyOCRText" public static let captureSelectionStyle = "capture_selectionStyle" + public static let captureOverlayMode = "capture_overlayMode" public static let captureHighResolution = "capture_highResolution" public static let captureImageFormat = "capture_imageFormat" public static let captureJPEGQuality = "capture_jpegQuality" @@ -52,6 +53,7 @@ public enum PreferenceDefaults { public static let captureAutoOCR = false public static let captureCopyOCRText = false public static let captureSelectionStyle = CaptureSelectionStyle.rectangle.rawValue + public static let captureOverlayMode = CaptureOverlayMode.live.rawValue public static let captureHighResolution = true public static let captureImageFormat = "png" public static let captureJPEGQuality = 0.9 @@ -83,3 +85,11 @@ public enum CaptureSelectionStyle: String, CaseIterable, Sendable { /// Freehand closed path that produces a transparent PNG. case freeform } + +/// Background behavior used while selecting an area to capture. +public enum CaptureOverlayMode: String, CaseIterable, Sendable { + /// Keep the overlay transparent so the current desktop remains visible. + case live + /// Display the frame captured immediately before the overlay appeared. + case snapshot +} diff --git a/Packages/SharedKit/Tests/SharedKitTests.swift b/Packages/SharedKit/Tests/SharedKitTests.swift index 5cb6737..12392a6 100644 --- a/Packages/SharedKit/Tests/SharedKitTests.swift +++ b/Packages/SharedKit/Tests/SharedKitTests.swift @@ -15,6 +15,7 @@ struct PreferenceKeysTests { PreferenceKeys.captureAutoOCR, PreferenceKeys.captureCopyOCRText, PreferenceKeys.captureSelectionStyle, + PreferenceKeys.captureOverlayMode, PreferenceKeys.captureHighResolution, PreferenceKeys.captureImageFormat, PreferenceKeys.captureJPEGQuality, @@ -43,6 +44,7 @@ struct PreferenceKeysTests { #expect(!PreferenceDefaults.captureAutoOCR) #expect(!PreferenceDefaults.captureCopyOCRText) #expect(PreferenceDefaults.captureSelectionStyle == CaptureSelectionStyle.rectangle.rawValue) + #expect(PreferenceDefaults.captureOverlayMode == CaptureOverlayMode.live.rawValue) #expect(PreferenceDefaults.captureHighResolution) #expect(!PreferenceDefaults.historySaveFullText) #expect(!PreferenceDefaults.forceUpdateAvailable) @@ -56,6 +58,13 @@ struct PreferenceKeysTests { #expect(AppearanceMode(rawValue: mode.rawValue)?.rawValue == mode.rawValue) } } + + @Test func captureOverlayModesHaveStablePersistedValues() { + #expect(CaptureOverlayMode.allCases.map(\.rawValue) == ["live", "snapshot"]) + for mode in CaptureOverlayMode.allCases { + #expect(CaptureOverlayMode(rawValue: mode.rawValue)?.rawValue == mode.rawValue) + } + } } struct UpdateServiceTests { diff --git a/README.md b/README.md index b68428b..425aafe 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@

- Download + Download

@@ -79,6 +79,7 @@ - 矩形区域 / 自由圈选 / 窗口 / 全屏 / 滚动截图 - 支持多显示器与混合缩放环境 - 实时十字准线,释放后二次调整选区 +- 区域选取支持实时画面与静态快照预览;快照不可用时使用纯黑背景 - 默认 Retina 像素,可切换标准 1x - 支持 PNG/JPEG 编码与 JPEG 质量设置 diff --git a/project.yml b/project.yml index a04a49b..4adec57 100644 --- a/project.yml +++ b/project.yml @@ -15,7 +15,7 @@ settings: SWIFT_VERSION: "6.0" MACOSX_DEPLOYMENT_TARGET: "13.0" ALWAYS_SEARCH_USER_PATHS: NO - MARKETING_VERSION: "0.7.0" + MARKETING_VERSION: "0.8.1" CURRENT_PROJECT_VERSION: "1" configs: Debug: @@ -96,6 +96,8 @@ targets: platform: macOS sources: - path: App/SnapGlass/EditorTests + - path: App/SnapGlass/Sources/AppLanguage.swift + - path: App/SnapGlass/Sources/AppLocalization.swift - path: App/SnapGlass/Sources/Editor excludes: - EditorView.swift diff --git a/release/v0.5.1/BUILD_INFO.json b/release/v0.5.1/BUILD_INFO.json deleted file mode 100644 index e48e0b9..0000000 --- a/release/v0.5.1/BUILD_INFO.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "version": "0.5.1", - "buildDate": "2026-08-05T08:30:00Z", - "app": "SnapGlass.app", - "configuration": "Release", - "architectures": ["arm64", "x86_64"] -} diff --git a/release/v0.5.1/SnapGlass-v0.5.1.dmg b/release/v0.5.1/SnapGlass-v0.5.1.dmg deleted file mode 100644 index 64c73c1..0000000 Binary files a/release/v0.5.1/SnapGlass-v0.5.1.dmg and /dev/null differ diff --git a/release/v0.5.1/SnapGlass-v0.5.1.dmg.sha256 b/release/v0.5.1/SnapGlass-v0.5.1.dmg.sha256 deleted file mode 100644 index bef55b5..0000000 --- a/release/v0.5.1/SnapGlass-v0.5.1.dmg.sha256 +++ /dev/null @@ -1 +0,0 @@ -f8bf4fd0d67f11d1a930606fe429d2ac25752ac96954f7f4a049f49794ac1dc6 /Users/11169285/Documents/Opencode project/snapocr/release/v0.5.1/SnapGlass-v0.5.1.dmg diff --git a/release/versions.json b/release/versions.json deleted file mode 100644 index ddc4182..0000000 --- a/release/versions.json +++ /dev/null @@ -1,9 +0,0 @@ -[ - { - "version": "0.5.1", - "date": "2026-08-05", - "file": "SnapGlass-v0.5.1.dmg", - "sha256": "release/v0.5.1/SnapGlass-v0.5.1.dmg.sha256", - "notes": "https://github.com/blackkcold/snapocr/releases/tag/v0.5.1" - } -] diff --git a/scripts/check-localization.sh b/scripts/check-localization.sh new file mode 100755 index 0000000..98184d0 --- /dev/null +++ b/scripts/check-localization.sh @@ -0,0 +1,103 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +RESOURCES="$PROJECT_ROOT/App/SnapGlass/Resources" +SOURCES="$PROJECT_ROOT/App/SnapGlass/Sources" + +LANGS=(en zh-Hans ja ko) +REFERENCE="en" +FAILED=0 + +keys_of() { + grep -oE '^"[^"]+"' "$1" | sort +} + +echo "=== Duplicate keys (values must be identical) ===" +for lang in "${LANGS[@]}"; do + file="$RESOURCES/$lang.lproj/Localizable.strings" + while IFS= read -r key; do + [ -z "$key" ] && continue + distinct=$(grep -F "$key = " "$file" | sed -E 's/^[^=]*= *//' | sort -u | wc -l | tr -d ' ') + if [ "$distinct" -gt 1 ]; then + echo " ❌ $lang: $key has $distinct different values" + FAILED=1 + fi + done < <(keys_of "$file" | uniq -d) +done +[ "$FAILED" -eq 0 ] && echo " ✅ no conflicting duplicates" + +echo "=== Key parity against $REFERENCE ===" +for lang in "${LANGS[@]}"; do + [ "$lang" == "$REFERENCE" ] && continue + missing=$(comm -23 <(keys_of "$RESOURCES/$REFERENCE.lproj/Localizable.strings") \ + <(keys_of "$RESOURCES/$lang.lproj/Localizable.strings")) + extra=$(comm -13 <(keys_of "$RESOURCES/$REFERENCE.lproj/Localizable.strings") \ + <(keys_of "$RESOURCES/$lang.lproj/Localizable.strings")) + if [ -n "$missing" ]; then + echo " ❌ $lang missing keys:"; echo "$missing" | sed 's/^/ /' + FAILED=1 + fi + if [ -n "$extra" ]; then + echo " ❌ $lang extra keys:"; echo "$extra" | sed 's/^/ /' + FAILED=1 + fi + [ -z "$missing" ] && [ -z "$extra" ] && echo " ✅ $lang key set matches" +done + +echo "=== Placeholder parity against $REFERENCE ===" +placeholders() { + grep -E '^"[^"]+" = ' "$1" | while IFS= read -r line; do + key=$(printf '%s' "$line" | sed -E 's/^"([^"]+)".*/\1/') + value=$(printf '%s' "$line" | sed -E 's/^"[^"]+" *= *"(.*)";[[:space:]]*$/\1/') + specs=$(printf '%s' "$value" | grep -oE '%[0-9]*[@dfs]' | sort | tr '\n' ',' || true) + printf '%s\t%s\n' "$key" "$specs" + done | sort +} +placeholders "$RESOURCES/$REFERENCE.lproj/Localizable.strings" > /tmp/checkloc_ref.txt +for lang in "${LANGS[@]}"; do + [ "$lang" == "$REFERENCE" ] && continue + placeholders "$RESOURCES/$lang.lproj/Localizable.strings" > /tmp/checkloc_lang.txt + diff_out=$(diff /tmp/checkloc_ref.txt /tmp/checkloc_lang.txt || true) + if [ -n "$diff_out" ]; then + echo " ❌ $lang placeholder mismatch:"; echo "$diff_out" | sed 's/^/ /' + FAILED=1 + else + echo " ✅ $lang placeholders match" + fi +done + +echo "=== Source-referenced keys present ===" +if ! python3 - "$SOURCES" "$RESOURCES/$REFERENCE.lproj/Localizable.strings" <<'PY' +import re, sys, pathlib +sources, catalog_path = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]) +catalog = set(re.findall(r'^"((?:[^"\\]|\\.)*)"\s*=', catalog_path.read_text(encoding="utf-8"), re.M)) +patterns = [ + re.compile(r'NSLocalizedString\(\s*"((?:[^"\\]|\\.)*)"'), + re.compile(r'String\(\s*localized:\s*"((?:[^"\\]|\\.)*)"'), + re.compile(r'AppLocalization\.string\(\s*"((?:[^"\\]|\\.)*)"'), +] +missing = set() +for path in sources.rglob("*.swift"): + text = path.read_text(encoding="utf-8") + for pattern in patterns: + for match in pattern.finditer(text): + if match.group(1) not in catalog: + missing.add((match.group(1), path.name)) +if missing: + for key, name in sorted(missing): + print(f" ❌ {key!r} referenced in {name} but absent from catalog") + sys.exit(1) +print(" ✅ all referenced keys exist") +PY +then + FAILED=1 +fi + +echo "" +if [ "$FAILED" -gt 0 ]; then + echo "❌ Localization check failed" + exit 1 +fi +echo "✅ Localization check passed" diff --git a/version.txt b/version.txt index faef31a..c18d72b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.7.0 +0.8.1 \ No newline at end of file