From 7e0fd73d286313b225fb3016b356f84ef4f58609 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=98=8A=E7=87=83?= <11169285@MacBook-Air-2.local> Date: Mon, 7 Sep 2026 20:01:23 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=8E=86=E5=8F=B2=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=94=B9=E4=B8=BA=E5=93=8D=E5=BA=94=E5=BC=8F=E6=88=AA?= =?UTF-8?q?=E5=9B=BE=E7=BD=91=E6=A0=BC=E5=B9=B6=E6=94=AF=E6=8C=81=E6=94=B6?= =?UTF-8?q?=E8=97=8F=E7=AD=9B=E9=80=89=E4=B8=8E=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 历史面板由单列列表改为自适应图片网格(3–5 列随窗口宽度),缩略图保留原始比例并加淡阴影;收藏星标可点击切换并持久化,新增全部/仅收藏/仅未收藏筛选与收藏分组置前/置后、时间正反排序。数据层新增 HistoryPresentation 排序与 setFavourite 接口。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../Windows/HistoryScreenshotCard.swift | 69 +++++++++++++++ .../Sources/Windows/HistoryView+Actions.swift | 41 ++++++++- .../Sources/Windows/HistoryView.swift | 83 +++++++++++++++---- .../Sources/HistoryActor+PublicAPI.swift | 12 +++ .../Sources/HistoryPresentation.swift | 40 +++++++++ .../HistoryPresentationTests.swift | 74 +++++++++++++++++ 6 files changed, 302 insertions(+), 17 deletions(-) create mode 100644 App/SnapGlass/Sources/Windows/HistoryScreenshotCard.swift create mode 100644 Packages/HistoryCore/Sources/HistoryPresentation.swift create mode 100644 Packages/HistoryCore/Tests/HistoryCoreTests/HistoryPresentationTests.swift diff --git a/App/SnapGlass/Sources/Windows/HistoryScreenshotCard.swift b/App/SnapGlass/Sources/Windows/HistoryScreenshotCard.swift new file mode 100644 index 0000000..c549fe4 --- /dev/null +++ b/App/SnapGlass/Sources/Windows/HistoryScreenshotCard.swift @@ -0,0 +1,69 @@ +import AppKit +import HistoryCore +import SwiftUI + +struct HistoryScreenshotCard: View { + let entry: HistoryEntry + let imageSize: CGSize? + let isSelected: Bool + let isUpdatingFavourite: Bool + let onSelect: () -> Void + let onOpen: () -> Void + let onFavourite: () -> Void + @State private var thumbnail: NSImage? + + private var aspectRatio: CGFloat { + guard let size = imageSize, size.width > 0, size.height > 0 else { return 4 / 3 } + return size.width / size.height + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .topTrailing) { + ZStack { + if let thumbnail { + Image(nsImage: thumbnail).resizable().scaledToFit() + } else { + Rectangle().fill(.quaternary) + .overlay { Image(systemName: "photo").foregroundStyle(.secondary) } + } + } + .aspectRatio(aspectRatio, contentMode: .fit) + .shadow(color: .black.opacity(0.12), radius: 4, x: 0, y: 2) + .contentShape(Rectangle()) + .onTapGesture(count: 2, perform: onOpen) + .onTapGesture(perform: onSelect) + .accessibilityLabel(Text(entry.timestamp, format: .dateTime)) + .accessibilityAction(named: Text("Open in Editor"), onOpen) + + Button(action: onFavourite) { + Image(systemName: entry.isFavourite ? "star.fill" : "star") + .foregroundStyle(entry.isFavourite ? Color.yellow : Color.primary) + .frame(width: 28, height: 28) + .background(.regularMaterial, in: Circle()) + } + .buttonStyle(.plain) + .padding(6) + .disabled(isUpdatingFavourite) + .help(entry.isFavourite ? "Remove favourite" : "Add favourite") + .accessibilityLabel(entry.isFavourite ? "Remove favourite" : "Add favourite") + } + Text(entry.timestamp, format: .dateTime.year().month().day().hour().minute()) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(5) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(isSelected ? Color.accentColor : .clear, lineWidth: 2) + .allowsHitTesting(false) + } + .task(id: entry.id) { + guard let history = HistoryActor.shared, + let data = try? await history.thumbnailData(for: entry.id), !Task.isCancelled + else { return } + thumbnail = NSImage(data: data) + } + } +} diff --git a/App/SnapGlass/Sources/Windows/HistoryView+Actions.swift b/App/SnapGlass/Sources/Windows/HistoryView+Actions.swift index 4c69dd0..b48c900 100644 --- a/App/SnapGlass/Sources/Windows/HistoryView+Actions.swift +++ b/App/SnapGlass/Sources/Windows/HistoryView+Actions.swift @@ -16,19 +16,39 @@ extension HistoryView { } func loadScreenshotEntries() async { + let loadID = UUID() + screenshotLoadID = loadID guard let history else { entries = [] return } do { - if searchQuery.isEmpty { + let query = searchQuery + let loaded: [HistoryEntry] + if query.isEmpty { let count = await history.count() - entries = try await history.recent(limit: max(count, 1)) + loaded = try await history.recent(limit: max(count, 1)) } else { - entries = try await history.search(query: searchQuery) + loaded = try await history.search(query: query) } + var sizes: [UUID: CGSize] = [:] + for entry in loaded { + guard !Task.isCancelled, screenshotLoadID == loadID else { return } + if let cached = thumbnailSizes[entry.id] { sizes[entry.id] = cached; continue } + if let data = try? await history.thumbnailData(for: entry.id), + let source = CGImageSourceCreateWithData(data as CFData, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? NSNumber, + let height = properties[kCGImagePropertyPixelHeight] as? NSNumber { + sizes[entry.id] = CGSize(width: width.doubleValue, height: height.doubleValue) + } + } + guard !Task.isCancelled, query == searchQuery, screenshotLoadID == loadID else { return } + thumbnailSizes = sizes + entries = loaded } catch { + guard screenshotLoadID == loadID else { return } entries = [] errorMessage = error.localizedDescription } @@ -60,6 +80,21 @@ extension HistoryView { } } + func toggleFavourite(_ entry: HistoryEntry) async { + guard let history, !updatingFavourites.contains(entry.id) else { return } + updatingFavourites.insert(entry.id) + defer { updatingFavourites.remove(entry.id) } + do { + if let updated = try await history.setFavourite(id: entry.id, isFavourite: !entry.isFavourite), + let index = entries.firstIndex(where: { $0.id == entry.id }) { + entries[index] = updated + } + await loadScreenshotEntries() + } catch { + errorMessage = error.localizedDescription + } + } + func deleteColorEntry(_ entry: ColorHistoryEntry) async { guard let colorHistory else { return } diff --git a/App/SnapGlass/Sources/Windows/HistoryView.swift b/App/SnapGlass/Sources/Windows/HistoryView.swift index b5c3ce4..a468095 100644 --- a/App/SnapGlass/Sources/Windows/HistoryView.swift +++ b/App/SnapGlass/Sources/Windows/HistoryView.swift @@ -20,6 +20,12 @@ struct HistoryView: View { @State private var isClearing = false @State private var isClearingColors = false @State private var selectedEntryID: HistoryEntry.ID? + @State private var favouriteFilter: HistoryPresentation.Filter = .all + @State private var favouriteOrder: HistoryPresentation.FavouriteOrder = .first + @State private var newestFirst = true + @State var thumbnailSizes: [UUID: CGSize] = [:] + @State var updatingFavourites: Set = [] + @State var screenshotLoadID = UUID() @State var errorMessage: String? @State private var searchTask: Task? @State var toastMessage: ToastMessage? @@ -46,7 +52,8 @@ struct HistoryView: View { switch segment { case .screenshots: searchBar - if entries.isEmpty { + historyFilters + if visibleEntries.isEmpty { emptyState } else { entryList @@ -133,7 +140,7 @@ struct HistoryView: View { if history == nil { return "History unavailable" } - return searchQuery.isEmpty ? "No captures yet" : "No results found" + return searchQuery.isEmpty && favouriteFilter == .all ? "No captures yet" : "No results found" } private var emptyState: some View { @@ -192,21 +199,61 @@ struct HistoryView: View { // MARK: - Entry List private var entryList: some View { - List(entries, selection: $selectedEntryID) { entry in - HistoryRow(entry: entry) - .onTapGesture(count: 2) { - Task { await openInEditor(entry) } - } - .contextMenu { contextMenu(for: entry) } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - Task { await deleteEntry(entry) } - } label: { - Label("Delete", systemImage: "trash") + GeometryReader { geometry in + let count = HistoryPresentation.columnCount(for: geometry.size.width - 32) + ScrollView { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16, alignment: .top), count: count), + alignment: .leading, spacing: 20) { + ForEach(visibleEntries) { entry in + HistoryScreenshotCard( + entry: entry, imageSize: thumbnailSizes[entry.id], + isSelected: selectedEntryID == entry.id, + isUpdatingFavourite: updatingFavourites.contains(entry.id), + onSelect: { selectedEntryID = entry.id }, + onOpen: { + selectedEntryID = entry.id + Task { await openInEditor(entry) } + }, + onFavourite: { Task { await toggleFavourite(entry) } } + ) + .contextMenu { contextMenu(for: entry) } } } + .padding(16) + } } - .listStyle(.plain) + } + + private var visibleEntries: [HistoryEntry] { + HistoryPresentation.entries(entries, filter: favouriteFilter, + favouriteOrder: favouriteOrder, newestFirst: newestFirst) + } + + private var historyFilters: some View { + HStack { + Menu { + Picker("Show", selection: $favouriteFilter) { + Text("All screenshots").tag(HistoryPresentation.Filter.all) + Text("Favourites only").tag(HistoryPresentation.Filter.favourites) + Text("Unfavourited only").tag(HistoryPresentation.Filter.unfavourited) + } + Picker("Favourite order", selection: $favouriteOrder) { + Text("Favourites first").tag(HistoryPresentation.FavouriteOrder.first) + Text("Favourites last").tag(HistoryPresentation.FavouriteOrder.last) + } + Picker("Time order", selection: $newestFirst) { + Text("Newest first").tag(true) + Text("Oldest first").tag(false) + } + } label: { + Label("Filter and sort", systemImage: "line.3.horizontal.decrease.circle") + } + .fixedSize() + Spacer() + Text("\(visibleEntries.count)").foregroundStyle(.secondary).monospacedDigit() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) } // MARK: - Color Grid @@ -239,6 +286,14 @@ struct HistoryView: View { @ViewBuilder private func contextMenu(for entry: HistoryEntry) -> some View { + Button { + Task { await toggleFavourite(entry) } + } label: { + Label(entry.isFavourite ? "Remove favourite" : "Add favourite", + systemImage: entry.isFavourite ? "star.slash" : "star") + } + .disabled(updatingFavourites.contains(entry.id)) + Button { Task { await openInEditor(entry) } } label: { diff --git a/Packages/HistoryCore/Sources/HistoryActor+PublicAPI.swift b/Packages/HistoryCore/Sources/HistoryActor+PublicAPI.swift index a74ac06..8bda173 100644 --- a/Packages/HistoryCore/Sources/HistoryActor+PublicAPI.swift +++ b/Packages/HistoryCore/Sources/HistoryActor+PublicAPI.swift @@ -3,6 +3,18 @@ import Foundation // MARK: - HistoryActor Additional Public API extension HistoryActor { + /// Atomically updates favourite metadata without rewriting screenshot data. + /// Returns nil if the entry was deleted before the update. + public func setFavourite(id: UUID, isFavourite: Bool) throws -> HistoryEntry? { + guard var entry = entries[id] ?? loadEntryFromDiskSync(id: id) else { return nil } + entry.isFavourite = isFavourite + try persistEntry(entry) + entries[id] = entry + // Preserve other cold entries: this cache is a snapshot, not a lazy loader. + diskCache[id] = entry + return entry + } + /// 获取所有内存缓存的条目 /// /// 不触发磁盘读取,仅返回当前在内存中的条目。 diff --git a/Packages/HistoryCore/Sources/HistoryPresentation.swift b/Packages/HistoryCore/Sources/HistoryPresentation.swift new file mode 100644 index 0000000..6833644 --- /dev/null +++ b/Packages/HistoryCore/Sources/HistoryPresentation.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Screenshot filtering and deterministic ordering, independent of the view layer. +public struct HistoryPresentation: Sendable { + /// The subset of favourite states to display. + public enum Filter: String, CaseIterable, Sendable { + case all, favourites, unfavourited + } + + /// Whether favourites precede or follow other captures. + public enum FavouriteOrder: String, CaseIterable, Sendable { + case first, last + } + + /// Returns favourite groups sorted by timestamp, with stable ID tie-breaking. + public static func entries( + _ entries: [HistoryEntry], filter: Filter, favouriteOrder: FavouriteOrder, newestFirst: Bool + ) -> [HistoryEntry] { + entries.filter { entry in + switch filter { + case .all: true + case .favourites: entry.isFavourite + case .unfavourited: !entry.isFavourite + } + }.sorted { lhs, rhs in + if lhs.isFavourite != rhs.isFavourite { + return favouriteOrder == .first ? lhs.isFavourite : !lhs.isFavourite + } + if lhs.timestamp != rhs.timestamp { + return newestFirst ? lhs.timestamp > rhs.timestamp : lhs.timestamp < rhs.timestamp + } + return lhs.id.uuidString < rhs.id.uuidString + } + } + + /// Three to five columns normally, with fewer columns in narrow windows. + public static func columnCount(for width: Double) -> Int { + min(5, max(1, Int(max(width, 0) / 180))) + } +} diff --git a/Packages/HistoryCore/Tests/HistoryCoreTests/HistoryPresentationTests.swift b/Packages/HistoryCore/Tests/HistoryCoreTests/HistoryPresentationTests.swift new file mode 100644 index 0000000..ca7b3a8 --- /dev/null +++ b/Packages/HistoryCore/Tests/HistoryCoreTests/HistoryPresentationTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing + +@testable import HistoryCore + +struct HistoryPresentationTests { + @Test func filtersAndSortsGroupsInBothDirections() { + let entries = (0..<4).map { index in + HistoryEntry( + timestamp: Date(timeIntervalSince1970: Double(index)), + textContent: "OCR", ocrConfidence: 1, captureMode: "area", isFavourite: index % 2 == 0) + } + for newest in [true, false] { + for first in [true, false] { + let result = HistoryPresentation.entries( + entries, filter: .all, + favouriteOrder: first ? .first : .last, newestFirst: newest) + let expected = + first + ? (newest ? [2, 0, 3, 1] : [0, 2, 1, 3]) + : (newest ? [3, 1, 2, 0] : [1, 3, 0, 2]) + #expect(result.map(\.id) == expected.map { entries[$0].id }) + } + } + #expect( + HistoryPresentation.entries( + entries, filter: .favourites, + favouriteOrder: .first, newestFirst: true + ).allSatisfy { $0.isFavourite }) + #expect( + HistoryPresentation.entries( + entries, filter: .unfavourited, + favouriteOrder: .first, newestFirst: true + ).allSatisfy { !$0.isFavourite }) + } + + @Test func gridColumnsStayBoundedAndTiesAreStable() { + #expect(HistoryPresentation.columnCount(for: 0) == 1) + #expect(HistoryPresentation.columnCount(for: 540) == 3) + #expect(HistoryPresentation.columnCount(for: 720) == 4) + #expect(HistoryPresentation.columnCount(for: 900) == 5) + #expect(HistoryPresentation.columnCount(for: 2_000) == 5) + let date = Date() + let entries = (0..<4).map { _ in + HistoryEntry(timestamp: date, textContent: "", ocrConfidence: 1, captureMode: "area") + } + let result = HistoryPresentation.entries(entries, filter: .all, favouriteOrder: .first, newestFirst: true) + #expect(result.map { $0.id.uuidString } == entries.map { $0.id.uuidString }.sorted()) + } + + @Test func favouriteUpdateSurvivesReloadAndPreservesColdEntries() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let history = try HistoryActor(baseURL: root) + let entry = HistoryEntry(textContent: "keep OCR", ocrConfidence: 1, captureMode: "area") + let other = HistoryEntry(textContent: "other", ocrConfidence: 1, captureMode: "window") + try await history.save(entry) + try await history.save(other) + let cold = try HistoryActor(baseURL: root) + await cold.evictHotCacheForTest() + let updated = try await cold.setFavourite(id: entry.id, isFavourite: true) + #expect(updated?.isFavourite == true) + #expect(updated?.textContent == "keep OCR") + #expect(await cold.count() == 2) + let reloaded = try HistoryActor(baseURL: root) + #expect(try await reloaded.load(id: entry.id)?.isFavourite == true) + #expect(try await reloaded.setFavourite(id: entry.id, isFavourite: false)?.isFavourite == false) + #expect(try await reloaded.setFavourite(id: UUID(), isFavourite: true) == nil) + } +} + +extension HistoryActor { + fileprivate func evictHotCacheForTest() { entries = [:] } +} From 983518c1122ad290ae933d80dbaeede2e68e4248 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=98=8A=E7=87=83?= <11169285@MacBook-Air-2.local> Date: Mon, 7 Sep 2026 20:01:46 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E6=A0=87=E6=B3=A8=E6=96=87?= =?UTF-8?q?=E5=AD=97=E6=94=B9=E4=B8=BA=E7=94=BB=E5=B8=83=E5=86=85=E5=8E=9F?= =?UTF-8?q?=E4=BD=8D=E5=A4=9A=E8=A1=8C=E7=BC=96=E8=BE=91=E5=B9=B6=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=94=AE=E4=BD=8D=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除文字工具的 SwiftUI alert 输入框,改为在点击位置叠加原生 NSTextView:新建与双击/检查器编辑统一走画布内编辑,实时显示位置、大小与换行;支持 Enter/Shift+Enter 提交与换行互换(默认 Enter 提交)及 Esc/失焦取消,空白提交不产生标注。ToastMessage/ToastType 抽离为独立文件供测试目标复用,新增 EditorInteractionTests 测试 scheme 与 SharedKit 键位行为测试。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../EditorTests/CanvasTextEntryTests.swift | 155 +++++++++++++++ .../CanvasTextRenderingTests.swift | 73 +++++++ .../Sources/Editor/CanvasTextEditor.swift | 186 ++++++++++++++++++ ...ditableAnnotationCanvasRepresentable.swift | 14 +- .../EditableAnnotationCanvasView+Mouse.swift | 4 + ...itableAnnotationCanvasView+Selection.swift | 1 + .../Editor/EditableAnnotationCanvasView.swift | 3 + App/SnapGlass/Sources/Editor/EditorView.swift | 18 +- .../Sources/Editor/EditorViewModel.swift | 24 ++- .../Sources/MenuBar/CaptureViewModel.swift | 47 ----- .../Sources/MenuBar/ToastMessage.swift | 38 ++++ .../Sources/Windows/PreferencesView.swift | 2 +- .../Windows/TextEntryPreferencesView.swift | 15 ++ .../SharedKit/Sources/PreferenceKeys.swift | 4 + .../Sources/TextEntryKeyBehavior.swift | 9 + .../Tests/TextEntryKeyBehaviorTests.swift | 22 +++ project.yml | 30 +++ 17 files changed, 583 insertions(+), 62 deletions(-) create mode 100644 App/SnapGlass/EditorTests/CanvasTextEntryTests.swift create mode 100644 App/SnapGlass/EditorTests/CanvasTextRenderingTests.swift create mode 100644 App/SnapGlass/Sources/Editor/CanvasTextEditor.swift create mode 100644 App/SnapGlass/Sources/MenuBar/ToastMessage.swift create mode 100644 App/SnapGlass/Sources/Windows/TextEntryPreferencesView.swift create mode 100644 Packages/SharedKit/Sources/TextEntryKeyBehavior.swift create mode 100644 Packages/SharedKit/Tests/TextEntryKeyBehaviorTests.swift diff --git a/App/SnapGlass/EditorTests/CanvasTextEntryTests.swift b/App/SnapGlass/EditorTests/CanvasTextEntryTests.swift new file mode 100644 index 0000000..8e9c2f0 --- /dev/null +++ b/App/SnapGlass/EditorTests/CanvasTextEntryTests.swift @@ -0,0 +1,155 @@ +import AnnotationCore +import AppKit +import Testing + +@MainActor +struct CanvasTextEntryTests { + private func image() throws -> CGImage { + let context = try #require( + CGContext( + data: nil, width: 800, height: 600, 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)) + return try #require(context.makeImage()) + } + + private func model() throws -> EditorViewModel { + let model = EditorViewModel() + model.document = model.interactor.createDocument(from: try image()) + return model + } + + @Test func draftCommitsOnceAndCanBeUndone() throws { + let model = try model() + model.beginTextEntry(at: CGPoint(x: 0.2, y: 0.4)) + model.textDraft = "Hello\n中文" + #expect(model.document?.nodes.isEmpty == true) + #expect(model.pendingTextNode != nil) + model.commitTextEntry() + #expect(!model.isEnteringText) + #expect(model.document?.nodes.count == 1) + #expect(model.document?.nodes.first?.text == "Hello\n中文") + model.undo() + #expect(model.document?.nodes.isEmpty == true) + model.redo() + #expect(model.document?.nodes.count == 1) + } + + @Test func cancelAndEmptyEditPreserveExistingText() throws { + let model = try model() + model.beginTextEntry(at: CGPoint(x: 0.3, y: 0.3)) + model.textDraft = "original" + model.commitTextEntry() + let original = try #require(model.document?.nodes.first) + model.beginTextEditing(original) + model.textDraft = "changed" + model.cancelTextEntry() + #expect(model.document?.nodes.first?.text == "original") + model.beginTextEditing(original) + model.textDraft = " \n " + model.commitTextEntry() + #expect(model.document?.nodes.first?.text == "original") + #expect(!model.isEnteringText) + model.beginTextEditing(original) + model.textDraft = "updated\ntext" + model.commitTextEntry() + #expect(model.document?.nodes.count == 1) + #expect(model.document?.nodes.first?.text == "updated\ntext") + model.undo() + #expect(model.document?.nodes.first?.text == "original") + } + + @Test func emptyNewTextCreatesNothingAndToolSwitchCancels() throws { + let model = try model() + model.beginTextEntry(at: .zero) + model.commitTextEntry() + #expect(model.document?.nodes.isEmpty == true) + model.beginTextEntry(at: .zero) + model.textDraft = "unfinished" + model.activateTool(.arrow) + #expect(!model.isEnteringText) + #expect(model.pendingTextNode == nil) + #expect(model.document?.nodes.isEmpty == true) + } + + @Test func canvasEditorTracksImageCoordinatesAndResize() 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: "Hello\n中文", fontSize: 24) + let session = UUID() + var result = "" + canvas.updateTextEntry(id: session, node: node, onCommit: { result = $0 }, onCancel: {}) + let editor = try #require(canvas.canvasTextEditor) + #expect(abs(editor.frame.minX - 160) < 0.01) + #expect(abs(editor.frame.minY - 180) < 0.01) + #expect(editor.string == "Hello\n中文") + let initialSize = editor.frame.size + canvas.setFrameSize(CGSize(width: 400, height: 300)) + canvas.layoutTextEntry() + #expect(abs(editor.frame.width - initialSize.width / 2) < 0.01) + #expect(abs(editor.frame.height - initialSize.height / 2) < 0.01) + // A representable update must not reset typing or create a new field. + editor.string = "draft" + canvas.updateTextEntry(id: session, node: node, onCommit: { _ in }, onCancel: {}) + #expect(canvas.canvasTextEditor === editor) + #expect(editor.string == "draft") + editor.onCommit?(editor.string) + #expect(result == "draft") + #expect(canvas.canvasTextEditor == nil) + } + + @Test func nativeReturnModesAndEscape() throws { + for newline in [false, true] { + let editor = CanvasTextEditor(frame: CGRect(x: 0, y: 0, width: 300, height: 100)) + editor.enterInsertsNewline = { newline } + editor.string = "hello" + editor.setSelectedRange(NSRange(location: 5, length: 0)) + var submitted = false + var cancelled = false + editor.onCommit = { _ in submitted = true } + editor.onCancel = { cancelled = true } + let newlineEvent = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, + modifierFlags: newline ? [] : [.shift], timestamp: 0, windowNumber: 0, context: nil, + characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)) + editor.keyDown(with: newlineEvent) + #expect(editor.string == "hello\n") + #expect(!submitted) + let submitEvent = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, + modifierFlags: newline ? [.shift] : [], timestamp: 0, windowNumber: 0, context: nil, + characters: "\r", charactersIgnoringModifiers: "\r", isARepeat: false, keyCode: 36)) + editor.keyDown(with: submitEvent) + #expect(submitted) + let escape = try #require( + NSEvent.keyEvent( + with: .keyDown, location: .zero, + modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, + characters: "\u{1b}", charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53)) + editor.keyDown(with: escape) + #expect(cancelled) + } + } + + @Test func clickingCanvasCancelsRatherThanCreatingAnotherText() throws { + let canvas = EditableAnnotationCanvasNSView(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) + canvas.image = try image() + canvas.currentTool = .text + var cancelled = false + let node = AnnotationNode(tool: .text, points: [.zero], text: "draft") + canvas.updateTextEntry(id: UUID(), node: node, onCommit: { _ in }, onCancel: { cancelled = true }) + let click = try #require( + NSEvent.mouseEvent( + with: .leftMouseDown, location: CGPoint(x: 500, y: 500), + modifierFlags: [], timestamp: 0, windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)) + canvas.mouseDown(with: click) + #expect(cancelled) + #expect(canvas.canvasTextEditor == nil) + } +} diff --git a/App/SnapGlass/EditorTests/CanvasTextRenderingTests.swift b/App/SnapGlass/EditorTests/CanvasTextRenderingTests.swift new file mode 100644 index 0000000..fde0373 --- /dev/null +++ b/App/SnapGlass/EditorTests/CanvasTextRenderingTests.swift @@ -0,0 +1,73 @@ +import AnnotationCore +import AppKit +import Testing + +@MainActor +struct CanvasTextRenderingTests { + @Test func inlineGlyphBoundsMatchCommittedText() throws { + let context = try #require( + CGContext( + data: nil, width: 800, height: 600, 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)) + let image = try #require(context.makeImage()) + let canvas = EditableAnnotationCanvasNSView(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) + canvas.image = image + canvas.showsOCROverlay = false + let window = NSWindow(contentRect: canvas.bounds, styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = canvas + defer { + canvas.endTextEntry() + window.close() + } + let model = EditorViewModel() + model.document = model.interactor.createDocument(from: image) + model.beginTextEntry(at: CGPoint(x: 0.2, y: 0.3)) + model.textDraft = "Hello canvas\n中文标注" + let node = try #require(model.pendingTextNode) + canvas.updateTextEntry(id: UUID(), node: node, onCommit: { _ in }, onCancel: {}) + canvas.layoutSubtreeIfNeeded() + canvas.displayIfNeeded() + let bitmap = try #require(canvas.bitmapImageRepForCachingDisplay(in: canvas.bounds)) + canvas.cacheDisplay(in: canvas.bounds, to: bitmap) + let inline = try redBounds(bitmap) + model.commitTextEntry() + let document = try #require(model.document) + let rendered = try Renderer().render(document) + let committed = try redBounds(NSBitmapImageRep(cgImage: rendered)) + // Compare normalized geometry because cacheDisplay can use a Retina backing scale. + #expect(abs(inline.minX - committed.minX) < 0.015) + #expect(abs(inline.minY - committed.minY) < 0.015) + #expect(abs(inline.width - committed.width) < 0.015) + #expect(abs(inline.height - committed.height) < 0.015) + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().appendingPathComponent("release") + let artifact = root.appendingPathComponent("text-preview-\(UUID().uuidString).png") + try bitmap.representation(using: .png, properties: [:])?.write(to: artifact) + print("Inline text render evidence: \(artifact.path)") + let finalArtifact = root.appendingPathComponent("text-committed-\(UUID().uuidString).png") + try NSBitmapImageRep(cgImage: rendered).representation(using: .png, properties: [:])?.write(to: finalArtifact) + print("Committed text render evidence: \(finalArtifact.path); inline \(inline), committed \(committed)") + } + + private func redBounds(_ bitmap: NSBitmapImageRep) throws -> CGRect { + var rect = CGRect.null + for row in 0.. 0.6, + color.redComponent > color.greenComponent * 1.5, + color.redComponent > color.blueComponent * 1.5 + else { continue } + rect = rect.union(CGRect(x: column, y: row, width: 1, height: 1)) + } + } + #expect(!rect.isNull, "Text must be visible while editing and after commit") + return CGRect( + x: rect.minX / CGFloat(bitmap.pixelsWide), y: rect.minY / CGFloat(bitmap.pixelsHigh), + width: rect.width / CGFloat(bitmap.pixelsWide), height: rect.height / CGFloat(bitmap.pixelsHigh)) + } +} diff --git a/App/SnapGlass/Sources/Editor/CanvasTextEditor.swift b/App/SnapGlass/Sources/Editor/CanvasTextEditor.swift new file mode 100644 index 0000000..10d4a8e --- /dev/null +++ b/App/SnapGlass/Sources/Editor/CanvasTextEditor.swift @@ -0,0 +1,186 @@ +import AnnotationCore +import AppKit +import SharedKit + +/// A native multiline field whose bounds use image pixels, not screen points. +/// This preserves type size and horizontal scaling while the canvas resizes. +final class CanvasTextEditor: NSTextView { + var sourceNode: AnnotationNode? + var onCommit: ((String) -> Void)? + var onCancel: (() -> Void)? + var onLayoutRequested: (() -> Void)? + var isFinishing = false + var enterInsertsNewline: () -> Bool = { + UserDefaults.standard.object(forKey: PreferenceKeys.editorEnterInsertsNewline) as? Bool + ?? PreferenceDefaults.editorEnterInsertsNewline + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + NotificationCenter.default.removeObserver(self, name: NSWindow.didResignKeyNotification, object: nil) + if let window { + NotificationCenter.default.addObserver( + self, selector: #selector(cancelOnWindowDeactivation), + name: NSWindow.didResignKeyNotification, object: window) + } + } + + @objc private func cancelOnWindowDeactivation(_ notification: Notification) { + if !isFinishing { onCancel?() } + } + + override func keyDown(with event: NSEvent) { + // Let the input method confirm/cancel its candidate before handling shortcuts. + if hasMarkedText() { + super.keyDown(with: event) + return + } + if event.keyCode == 53 { + onCancel?() + return + } + if event.keyCode == 36 || event.keyCode == 76 { + if TextEntryKeyBehavior.shouldSubmit( + enterInsertsNewline: enterInsertsNewline(), + shift: event.modifierFlags.contains(.shift), hasMarkedText: false + ) { + onCommit?(string) + } else { + insertNewlineIgnoringFieldEditor(self) + } + return + } + super.keyDown(with: event) + } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if event.keyCode == 53 || event.keyCode == 36 || event.keyCode == 76 { + keyDown(with: event) + return true + } + if event.modifierFlags.contains(.command) { + switch event.charactersIgnoringModifiers?.lowercased() { + case "z": + if event.modifierFlags.contains(.shift) { undoManager?.redo() } else { undoManager?.undo() } + case "a": selectAll(self) + case "c": copy(self) + case "v": pasteAsPlainText(self) + case "x": cut(self) + default: return super.performKeyEquivalent(with: event) + } + return true + } + return super.performKeyEquivalent(with: event) + } + + override func didChangeText() { + super.didChangeText() + onLayoutRequested?() + } + + override func resignFirstResponder() -> Bool { + let resigned = super.resignFirstResponder() + if resigned, !isFinishing { + // Do not publish SwiftUI state from within an AppKit focus update. + Task { @MainActor [weak self] in + guard let self, !self.isFinishing else { return } + self.onCancel?() + } + } + return resigned + } +} + +extension EditableAnnotationCanvasNSView { + func updateTextEntry( + id: UUID, node: AnnotationNode?, onCommit: @escaping (String) -> Void, onCancel: @escaping () -> Void + ) { + guard let node else { + endTextEntry() + return + } + if canvasTextEntryID == id { return } + endTextEntry() + canvasTextEntryID = id + let editor = makeTextEditor(for: node) + editor.onLayoutRequested = { [weak self] in self?.layoutTextEntry() } + editor.onCommit = { [weak self] text in + guard let self, self.canvasTextEntryID == id else { return } + self.endTextEntry() + onCommit(text) + } + editor.onCancel = { [weak self] in + guard let self, self.canvasTextEntryID == id else { return } + self.endTextEntry() + onCancel() + } + canvasTextEditor = editor + addSubview(editor) + layoutTextEntry() + Task { @MainActor [weak self, weak editor] in + guard let self, let editor, self.canvasTextEntryID == id else { return } + self.window?.makeFirstResponder(editor) + editor.setSelectedRange(NSRange(location: editor.string.utf16.count, length: 0)) + } + } + + private func makeTextEditor(for node: AnnotationNode) -> CanvasTextEditor { + let editor = CanvasTextEditor(frame: .zero) + editor.sourceNode = node + editor.isRichText = false + editor.importsGraphics = false + editor.allowsUndo = true + editor.isVerticallyResizable = false + editor.isHorizontallyResizable = false + 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.drawsBackground = node.fillColor != nil + editor.backgroundColor = NSColor(cgColor: node.fillColor ?? NSColor.clear.cgColor) ?? .clear + editor.alignment = + switch node.textAlignment { + case .leading: .left + 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 + } + + func endTextEntry() { + guard let editor = canvasTextEditor else { return } + editor.isFinishing = true + if window?.firstResponder === editor { window?.makeFirstResponder(self) } + editor.removeFromSuperview() + canvasTextEditor = nil + canvasTextEntryID = nil + } + + func layoutTextEntry() { + 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 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.textContainer?.containerSize = CGSize( + width: max((width - 8) / node.textHorizontalScale, 1), height: max(height - 8, 1) + ) + } +} diff --git a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasRepresentable.swift b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasRepresentable.swift index 978cd5f..9e4e8f6 100644 --- a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasRepresentable.swift +++ b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasRepresentable.swift @@ -31,6 +31,10 @@ struct EditableAnnotationCanvasView: NSViewRepresentable { var onDeleteSelection: () -> Void var onTextRequested: (CGPoint) -> Void var onTextEditRequested: (AnnotationNode) -> Void + let textEntryID: UUID + let textEntryNode: AnnotationNode? + var onTextCommit: (String) -> Void + var onTextCancel: () -> Void var onOCRLinesCopied: ([OCRLine]) -> Void var onOCRTextCopied: (String) -> Void var onOCRLineAsAnnotation: (OCRLine) -> Void @@ -41,7 +45,7 @@ struct EditableAnnotationCanvasView: NSViewRepresentable { func updateNSView(_ view: EditableAnnotationCanvasNSView, context: Context) { view.image = image - view.nodes = nodes + view.nodes = nodes.filter { $0.id != textEntryNode?.id } view.verticalCropOnly = verticalCropOnly view.currentTool = tool view.currentColor = color @@ -56,7 +60,7 @@ struct EditableAnnotationCanvasView: NSViewRepresentable { view.currentTextAlignment = textAlignment view.currentBlurMode = blurMode view.currentBlurIntensity = blurIntensity - view.selectedNodeID = selectedNodeID + view.selectedNodeID = textEntryNode == nil ? selectedNodeID : nil view.ocrLines = ocrLines view.showsOCROverlay = showsOCROverlay view.onNodeCreated = onNodeCreated @@ -65,6 +69,8 @@ struct EditableAnnotationCanvasView: NSViewRepresentable { view.onDeleteSelection = onDeleteSelection view.onTextRequested = onTextRequested view.onTextEditRequested = onTextEditRequested + view.updateTextEntry(id: textEntryID, node: textEntryNode, + onCommit: onTextCommit, onCancel: onTextCancel) view.onOCRLinesCopied = onOCRLinesCopied view.onOCRTextCopied = onOCRTextCopied view.onOCRLineAsAnnotation = onOCRLineAsAnnotation @@ -75,4 +81,8 @@ struct EditableAnnotationCanvasView: NSViewRepresentable { view.updateOCRTextOverlay() view.needsDisplay = true } + + static func dismantleNSView(_ view: EditableAnnotationCanvasNSView, coordinator: ()) { + view.endTextEntry() + } } diff --git a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+Mouse.swift b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+Mouse.swift index a8de310..5512eff 100644 --- a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+Mouse.swift +++ b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+Mouse.swift @@ -8,6 +8,10 @@ import SwiftUI extension EditableAnnotationCanvasNSView { override func mouseDown(with event: NSEvent) { + if let editor = canvasTextEditor { + editor.onCancel?() + return + } window?.makeFirstResponder(self) let point = convert(event.locationInWindow, from: nil) guard imageDisplayRect.contains(point) else { return } diff --git a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+Selection.swift b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+Selection.swift index 5c0c07a..d3b6b4a 100644 --- a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+Selection.swift +++ b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView+Selection.swift @@ -222,6 +222,7 @@ extension EditableAnnotationCanvasNSView { override func layout() { super.layout() updateOCRTextOverlay() + layoutTextEntry() } func previewImage(for image: CGImage) -> CGImage? { diff --git a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView.swift b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView.swift index 82ec3b9..6f333de 100644 --- a/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView.swift +++ b/App/SnapGlass/Sources/Editor/EditableAnnotationCanvasView.swift @@ -101,6 +101,9 @@ final class EditableAnnotationCanvasNSView: NSView { var pickerHoverColor: SampledColor? var pickerRegionRect: CGRect = .zero var dominantColorCount = 5 + var canvasTextEditor: CanvasTextEditor? + var canvasTextEntryID: UUID? + override var acceptsFirstResponder: Bool { true } diff --git a/App/SnapGlass/Sources/Editor/EditorView.swift b/App/SnapGlass/Sources/Editor/EditorView.swift index cff41cf..e9eae00 100644 --- a/App/SnapGlass/Sources/Editor/EditorView.swift +++ b/App/SnapGlass/Sources/Editor/EditorView.swift @@ -80,6 +80,13 @@ struct EditorView: View { editorVM.beginTextEntry(at: point) }, onTextEditRequested: editorVM.beginTextEditing, + textEntryID: editorVM.textEntryID, + textEntryNode: editorVM.pendingTextNode, + onTextCommit: { text in + editorVM.textDraft = text + editorVM.commitTextEntry() + }, + onTextCancel: editorVM.cancelTextEntry, onOCRLinesCopied: editorVM.copyOCRLines, onOCRTextCopied: editorVM.copyOCRSelection, onOCRLineAsAnnotation: editorVM.addOCRLineAsAnnotation, @@ -100,6 +107,7 @@ struct EditorView: View { } bottomBar + .disabled(editorVM.isEnteringText) } .frame(minWidth: 640, minHeight: 480) .toast(message: $editorVM.toastMessage) @@ -108,15 +116,7 @@ struct EditorView: View { NSApplication.shared.keyWindow?.close() } } - .alert(editorVM.selectedNode?.tool == .text ? "Edit Text" : "Add Text", isPresented: $editorVM.isEnteringText) { - TextField("Text", text: $editorVM.textDraft) - Button("Cancel", role: .cancel) { - editorVM.cancelTextEntry() - } - Button("Add") { - editorVM.commitTextEntry() - } - } + .onDisappear { editorVM.cancelTextEntry() } } private var bottomBar: some View { diff --git a/App/SnapGlass/Sources/Editor/EditorViewModel.swift b/App/SnapGlass/Sources/Editor/EditorViewModel.swift index a954344..be900d0 100644 --- a/App/SnapGlass/Sources/Editor/EditorViewModel.swift +++ b/App/SnapGlass/Sources/Editor/EditorViewModel.swift @@ -73,8 +73,9 @@ public final class EditorViewModel: ObservableObject { /// Text currently being entered for a pending text annotation. @Published public var textDraft = "" - /// Whether the text entry dialog is visible. + /// Whether the in-canvas text editor is active. @Published public var isEnteringText = false + @Published private(set) var textEntryID = UUID() /// Whether undo is available. public var canUndo: Bool { document?.canUndo == true } @@ -172,6 +173,7 @@ public final class EditorViewModel: ObservableObject { /// /// - Parameter image: The captured background image to annotate. public func loadImage(_ image: CGImage) { + cancelTextEntry() cancelBarcodeScan() document = interactor.createDocument(from: image) selectedNodeID = nil @@ -183,6 +185,7 @@ public final class EditorViewModel: ObservableObject { // MARK: - Annotation Operations func activateTool(_ tool: EditorTool) { + cancelTextEntry() isVerticalTrimEnabled = false selectedTool = tool if tool == .ocr { @@ -411,6 +414,7 @@ public final class EditorViewModel: ObservableObject { /// Starts text entry at a normalized image coordinate. public func beginTextEntry(at point: CGPoint) { + textEntryID = UUID() pendingTextPoint = point editingTextNodeID = nil textDraft = "" @@ -419,12 +423,26 @@ public final class EditorViewModel: ObservableObject { 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 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) + ) + } + /// Commits the pending text annotation if it contains visible characters. public func commitTextEntry() { defer { @@ -434,8 +452,8 @@ public final class EditorViewModel: ObservableObject { isEnteringText = false } - let text = textDraft.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty else { return } + 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 diff --git a/App/SnapGlass/Sources/MenuBar/CaptureViewModel.swift b/App/SnapGlass/Sources/MenuBar/CaptureViewModel.swift index c0fd426..7d1e98b 100644 --- a/App/SnapGlass/Sources/MenuBar/CaptureViewModel.swift +++ b/App/SnapGlass/Sources/MenuBar/CaptureViewModel.swift @@ -194,53 +194,6 @@ public final class CaptureViewModel: ObservableObject { } } -/// Represents a toast message. -public struct ToastMessage: Equatable { - /// Stable identity for presentation and dismissal. - public let id: UUID - - /// The message text. - public let message: String - - /// The type of toast. - public let type: ToastType - - /// Optional title for a user action. - public let actionLabel: String? - - let action: (@MainActor () -> Void)? - - init( - id: UUID = UUID(), - message: String, - type: ToastType, - actionLabel: String? = nil, - action: (@MainActor () -> Void)? = nil - ) { - self.id = id - self.message = message - self.type = type - self.actionLabel = actionLabel - self.action = action - } - - public static func == (lhs: ToastMessage, rhs: ToastMessage) -> Bool { - lhs.id == rhs.id - } -} - -/// The type of toast notification. -public enum ToastType { - /// A success notification. - case success - - /// An error notification. - case error - - /// An informational notification. - case info -} - /// 截图来源应用信息,用于历史记录保存。 struct CaptureSourceInfo { let appName: String? diff --git a/App/SnapGlass/Sources/MenuBar/ToastMessage.swift b/App/SnapGlass/Sources/MenuBar/ToastMessage.swift new file mode 100644 index 0000000..1dc8623 --- /dev/null +++ b/App/SnapGlass/Sources/MenuBar/ToastMessage.swift @@ -0,0 +1,38 @@ +import Foundation + +/// Represents a toast message without coupling consumers to capture services. +public struct ToastMessage: Equatable { + /// Stable identity for presentation and dismissal. + public let id: UUID + /// The message text. + public let message: String + /// The type of toast. + public let type: ToastType + /// Optional title for a user action. + public let actionLabel: String? + let action: (@MainActor () -> Void)? + + init( + id: UUID = UUID(), message: String, type: ToastType, + actionLabel: String? = nil, action: (@MainActor () -> Void)? = nil + ) { + self.id = id + self.message = message + self.type = type + self.actionLabel = actionLabel + self.action = action + } + + /// Messages compare by their stable presentation identity. + public static func == (lhs: ToastMessage, rhs: ToastMessage) -> Bool { lhs.id == rhs.id } +} + +/// The type of toast notification. +public enum ToastType { + /// A success notification. + case success + /// An error notification. + case error + /// An informational notification. + case info +} diff --git a/App/SnapGlass/Sources/Windows/PreferencesView.swift b/App/SnapGlass/Sources/Windows/PreferencesView.swift index d88093e..38111c2 100644 --- a/App/SnapGlass/Sources/Windows/PreferencesView.swift +++ b/App/SnapGlass/Sources/Windows/PreferencesView.swift @@ -282,9 +282,9 @@ struct CapturePreferencesView: View { ) 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.") diff --git a/App/SnapGlass/Sources/Windows/TextEntryPreferencesView.swift b/App/SnapGlass/Sources/Windows/TextEntryPreferencesView.swift new file mode 100644 index 0000000..00ca1fe --- /dev/null +++ b/App/SnapGlass/Sources/Windows/TextEntryPreferencesView.swift @@ -0,0 +1,15 @@ +import SharedKit +import SwiftUI + +struct TextEntryPreferencesView: View { + @AppStorage(PreferenceKeys.editorEnterInsertsNewline) + private var enterInsertsNewline = PreferenceDefaults.editorEnterInsertsNewline + + var body: some View { + Toggle("Enter inserts a newline in text annotations", isOn: $enterInsertsNewline) + PreferencesCardCaption( + text: enterInsertsNewline + ? "Enter: new line. Shift+Enter: submit. Escape: cancel." + : "Enter: submit. Shift+Enter: new line. Escape: cancel.") + } +} diff --git a/Packages/SharedKit/Sources/PreferenceKeys.swift b/Packages/SharedKit/Sources/PreferenceKeys.swift index ee3a15f..3349f72 100644 --- a/Packages/SharedKit/Sources/PreferenceKeys.swift +++ b/Packages/SharedKit/Sources/PreferenceKeys.swift @@ -2,6 +2,8 @@ import Foundation /// UserDefaults keys shared by the app and feature modules. public enum PreferenceKeys { + /// Whether Return inserts a newline instead of committing an annotation. + public static let editorEnterInsertsNewline = "editor_enterInsertsNewline" public static let launchAtLogin = "general_launchAtLogin" public static let appLanguage = "general_appLanguage" public static let appearanceMode = "appearance_mode" @@ -38,6 +40,8 @@ public enum PreferenceKeys { /// Defaults used when a preference has not been written yet. public enum PreferenceDefaults { + /// Preserve Return-to-submit as the default text editing behavior. + public static let editorEnterInsertsNewline = false public static let launchAtLogin = false public static let appLanguage = "system" public static let appearanceMode = "system" diff --git a/Packages/SharedKit/Sources/TextEntryKeyBehavior.swift b/Packages/SharedKit/Sources/TextEntryKeyBehavior.swift new file mode 100644 index 0000000..43366a4 --- /dev/null +++ b/Packages/SharedKit/Sources/TextEntryKeyBehavior.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Keyboard policy shared by the canvas editor and its preference tests. +public enum TextEntryKeyBehavior { + /// Shift reverses the configured Return action. Marked text belongs to the IME. + public static func shouldSubmit(enterInsertsNewline: Bool, shift: Bool, hasMarkedText: Bool) -> Bool { + !hasMarkedText && (enterInsertsNewline == shift) + } +} diff --git a/Packages/SharedKit/Tests/TextEntryKeyBehaviorTests.swift b/Packages/SharedKit/Tests/TextEntryKeyBehaviorTests.swift new file mode 100644 index 0000000..8848186 --- /dev/null +++ b/Packages/SharedKit/Tests/TextEntryKeyBehaviorTests.swift @@ -0,0 +1,22 @@ +import Testing + +@testable import SharedKit + +struct TextEntryKeyBehaviorTests { + @Test func returnAndShiftReturnAreOpposites() { + for newline in [true, false] { + #expect( + TextEntryKeyBehavior.shouldSubmit( + enterInsertsNewline: newline, shift: false, hasMarkedText: false) == !newline) + #expect( + TextEntryKeyBehavior.shouldSubmit( + enterInsertsNewline: newline, shift: true, hasMarkedText: false) == newline) + for shift in [true, false] { + #expect( + !TextEntryKeyBehavior.shouldSubmit( + enterInsertsNewline: newline, shift: shift, hasMarkedText: true)) + } + } + #expect(!PreferenceDefaults.editorEnterInsertsNewline) + } +} diff --git a/project.yml b/project.yml index 801a4b9..c0bef06 100644 --- a/project.yml +++ b/project.yml @@ -91,6 +91,29 @@ targets: else echo "warning: SwiftLint not installed" fi + EditorInteractionTests: + type: bundle.unit-test + platform: macOS + sources: + - path: App/SnapGlass/EditorTests + - path: App/SnapGlass/Sources/Editor + excludes: + - EditorView.swift + - AnnotationInspectorView.swift + - ToolPickerView.swift + - AnnotationCanvasView.swift + - path: App/SnapGlass/Sources/MenuBar/ToastMessage.swift + dependencies: + - package: SharedKit + - package: AnnotationCore + - package: OCRCore + - package: BarcodeCore + - package: HistoryCore + settings: + base: + GENERATE_INFOPLIST_FILE: YES + PRODUCT_MODULE_NAME: EditorInteractionTests + PRODUCT_NAME: EditorInteractionTests WindowLifecycleStateTests: type: bundle.unit-test platform: macOS @@ -106,6 +129,13 @@ targets: PRODUCT_MODULE_NAME: WindowLifecycleStateTests GENERATE_INFOPLIST_FILE: YES schemes: + EditorInteractionTests: + build: + targets: + EditorInteractionTests: test + test: + targets: + - EditorInteractionTests WindowLifecycleStateTests: build: targets: From 0d5c49188cf1c92cbee33c350aed7dc69fdca12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=98=8A=E7=87=83?= <11169285@MacBook-Air-2.local> Date: Mon, 7 Sep 2026 20:02:22 +0800 Subject: [PATCH 3/4] =?UTF-8?q?chore:=20=E8=A1=A5=E5=85=85=E5=9B=9B?= =?UTF-8?q?=E8=AF=AD=E8=A8=80=E7=95=8C=E9=9D=A2=E6=96=87=E6=A1=88=E5=B9=B6?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=8F=98=E6=9B=B4=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增历史筛选/收藏与文字编辑器键位相关文案(en/zh-Hans/ja/ko);CHANGELOG 记录 Unreleased 两项新功能;.gitignore 忽略本地验证产物与临时格式配置。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .gitignore | 6 ++++++ .../Resources/en.lproj/Localizable.strings | 16 ++++++++++++++++ .../Resources/ja.lproj/Localizable.strings | 16 ++++++++++++++++ .../Resources/ko.lproj/Localizable.strings | 16 ++++++++++++++++ .../Resources/zh-Hans.lproj/Localizable.strings | 16 ++++++++++++++++ CHANGELOG.md | 5 +++++ 6 files changed, 75 insertions(+) diff --git a/.gitignore b/.gitignore index 9fc9c9e..79edcfe 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,9 @@ release/manual-*/ # 本地实验打包产物(--experimental 模式,release/exp-*/),不入库 release/exp-*/ + +# Local UI validation evidence and compiler intermediates +release/validation-*/ +release/validation-format.json +release/text-preview-*.png +release/text-committed-*.png diff --git a/App/SnapGlass/Resources/en.lproj/Localizable.strings b/App/SnapGlass/Resources/en.lproj/Localizable.strings index 1a5bf63..733b89c 100644 --- a/App/SnapGlass/Resources/en.lproj/Localizable.strings +++ b/App/SnapGlass/Resources/en.lproj/Localizable.strings @@ -1,4 +1,20 @@ "General" = "General"; +"Show" = "Show"; +"All screenshots" = "All screenshots"; +"Favourites only" = "Favourites only"; +"Unfavourited only" = "Unfavourited only"; +"Favourite order" = "Favourite order"; +"Favourites first" = "Favourites first"; +"Favourites last" = "Favourites last"; +"Time order" = "Time order"; +"Newest first" = "Newest first"; +"Oldest first" = "Oldest first"; +"Filter and sort" = "Filter and sort"; +"Add favourite" = "Add favourite"; +"Remove favourite" = "Remove favourite"; +"Enter inserts a newline in text annotations" = "Enter inserts a newline in text annotations"; +"Enter: new line. Shift+Enter: submit. Escape: cancel." = "Enter: new line. Shift+Enter: submit. Escape: cancel."; +"Enter: submit. Shift+Enter: new line. Escape: cancel." = "Enter: submit. Shift+Enter: new line. Escape: cancel."; "Capture" = "Capture"; "OCR" = "OCR"; "Shortcuts" = "Shortcuts"; diff --git a/App/SnapGlass/Resources/ja.lproj/Localizable.strings b/App/SnapGlass/Resources/ja.lproj/Localizable.strings index d9ff2ac..d5565ed 100644 --- a/App/SnapGlass/Resources/ja.lproj/Localizable.strings +++ b/App/SnapGlass/Resources/ja.lproj/Localizable.strings @@ -1,4 +1,20 @@ "General" = "一般"; +"Show" = "表示"; +"All screenshots" = "すべてのスクリーンショット"; +"Favourites only" = "お気に入りのみ"; +"Unfavourited only" = "お気に入り以外"; +"Favourite order" = "お気に入りの順序"; +"Favourites first" = "お気に入りを先に"; +"Favourites last" = "お気に入りを後に"; +"Time order" = "日時の順序"; +"Newest first" = "新しい順"; +"Oldest first" = "古い順"; +"Filter and sort" = "絞り込みと並べ替え"; +"Add favourite" = "お気に入りに追加"; +"Remove favourite" = "お気に入りから削除"; +"Enter inserts a newline in text annotations" = "テキスト注釈で Enter を押すと改行"; +"Enter: new line. Shift+Enter: submit. Escape: cancel." = "Enter:改行。Shift+Enter:確定。Esc:キャンセル。"; +"Enter: submit. Shift+Enter: new line. Escape: cancel." = "Enter:確定。Shift+Enter:改行。Esc:キャンセル。"; "Capture" = "撮影"; "OCR" = "文字認識"; "Shortcuts" = "ショートカット"; diff --git a/App/SnapGlass/Resources/ko.lproj/Localizable.strings b/App/SnapGlass/Resources/ko.lproj/Localizable.strings index 0bf7c2e..39c1d0d 100644 --- a/App/SnapGlass/Resources/ko.lproj/Localizable.strings +++ b/App/SnapGlass/Resources/ko.lproj/Localizable.strings @@ -1,4 +1,20 @@ "General" = "일반"; +"Show" = "표시"; +"All screenshots" = "모든 스크린샷"; +"Favourites only" = "즐겨찾기만"; +"Unfavourited only" = "즐겨찾기 제외"; +"Favourite order" = "즐겨찾기 순서"; +"Favourites first" = "즐겨찾기 먼저"; +"Favourites last" = "즐겨찾기 나중에"; +"Time order" = "시간 순서"; +"Newest first" = "최신순"; +"Oldest first" = "오래된순"; +"Filter and sort" = "필터 및 정렬"; +"Add favourite" = "즐겨찾기 추가"; +"Remove favourite" = "즐겨찾기 해제"; +"Enter inserts a newline in text annotations" = "텍스트 주석에서 Enter로 줄바꿈"; +"Enter: new line. Shift+Enter: submit. Escape: cancel." = "Enter: 줄바꿈. Shift+Enter: 완료. Esc: 취소."; +"Enter: submit. Shift+Enter: new line. Escape: cancel." = "Enter: 완료. Shift+Enter: 줄바꿈. Esc: 취소."; "Capture" = "캡처"; "OCR" = "텍스트 인식"; "Shortcuts" = "단축키"; diff --git a/App/SnapGlass/Resources/zh-Hans.lproj/Localizable.strings b/App/SnapGlass/Resources/zh-Hans.lproj/Localizable.strings index 2a8866e..434ccb5 100644 --- a/App/SnapGlass/Resources/zh-Hans.lproj/Localizable.strings +++ b/App/SnapGlass/Resources/zh-Hans.lproj/Localizable.strings @@ -1,4 +1,20 @@ "General" = "通用"; +"Show" = "显示"; +"All screenshots" = "全部截图"; +"Favourites only" = "仅收藏"; +"Unfavourited only" = "仅未收藏"; +"Favourite order" = "收藏排序"; +"Favourites first" = "收藏在前"; +"Favourites last" = "收藏在后"; +"Time order" = "时间排序"; +"Newest first" = "从新到旧"; +"Oldest first" = "从旧到新"; +"Filter and sort" = "筛选与排序"; +"Add favourite" = "收藏"; +"Remove favourite" = "取消收藏"; +"Enter inserts a newline in text annotations" = "文字标注中按 Enter 换行"; +"Enter: new line. Shift+Enter: submit. Escape: cancel." = "Enter 换行,Shift+Enter 提交,Esc 取消。"; +"Enter: submit. Shift+Enter: new line. Escape: cancel." = "Enter 提交,Shift+Enter 换行,Esc 取消。"; "Capture" = "截图"; "OCR" = "文字识别"; "Shortcuts" = "快捷键"; diff --git a/CHANGELOG.md b/CHANGELOG.md index 98db06a..a95a70c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added +- 历史截图自适应图片网格:保留原始比例、淡阴影、时间及可切换收藏星标,支持收藏筛选、收藏分组与时间双向排序。 +- 文字标注改为画布内原位多行编辑,新建/双击编辑统一;支持 Enter 与 Shift+Enter 提交/换行互换设置,Esc 或失焦取消。 + + ### Changed - 代码规范:全量清理 SwiftLint 债务(295 → 0 违规),拆分超长文件为扩展/新文件,统一命名与行宽,`.swiftlint.yml` 显式排除各包 `.build` 目录并启用 `trailing_comma: mandatory_comma`,与 swift-format 对齐 From a5dc2686b7301b4cadda2e16ddf05c47df6b33f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=A8=E6=98=8A=E7=87=83?= <11169285@MacBook-Air-2.local> Date: Mon, 7 Sep 2026 20:03:27 +0800 Subject: [PATCH 4/4] =?UTF-8?q?release:=20v0.7.0=20=E2=80=94=20=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E8=AE=B0=E5=BD=95=E5=9B=BE=E7=89=87=E7=BD=91=E6=A0=BC?= =?UTF-8?q?=E4=B8=8E=E7=94=BB=E5=B8=83=E5=86=85=E6=96=87=E5=AD=97=E6=A0=87?= =?UTF-8?q?=E6=B3=A8=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 版本号升至 0.7.0,README 徽章与 CHANGELOG 同步更新。 Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- CHANGELOG.md | 5 ++++- README.md | 2 +- project.yml | 2 +- version.txt | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a95a70c..6208367 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.7.0] - 2026-09-07 + ### Added - 历史截图自适应图片网格:保留原始比例、淡阴影、时间及可切换收藏星标,支持收藏筛选、收藏分组与时间双向排序。 - 文字标注改为画布内原位多行编辑,新建/双击编辑统一;支持 Enter 与 Shift+Enter 提交/换行互换设置,Esc 或失焦取消。 @@ -191,7 +193,8 @@ 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.6.1...HEAD +[Unreleased]: https://github.com/blackkcold/snapocr/compare/v0.7.0...HEAD +[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 [0.5.6]: https://github.com/blackkcold/snapocr/compare/v0.5.5...v0.5.6 diff --git a/README.md b/README.md index 6da64a9..b68428b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@

- Download + Download

diff --git a/project.yml b/project.yml index c0bef06..a04a49b 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.6.1" + MARKETING_VERSION: "0.7.0" CURRENT_PROJECT_VERSION: "1" configs: Debug: diff --git a/version.txt b/version.txt index ee6cdce..faef31a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.6.1 +0.7.0