Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
155 changes: 155 additions & 0 deletions App/SnapGlass/EditorTests/CanvasTextEntryTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
73 changes: 73 additions & 0 deletions App/SnapGlass/EditorTests/CanvasTextRenderingTests.swift
Original file line number Diff line number Diff line change
@@ -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..<bitmap.pixelsHigh {
for column in 0..<bitmap.pixelsWide {
guard let color = bitmap.colorAt(x: column, y: row)?.usingColorSpace(.sRGB),
color.redComponent > 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))
}
}
16 changes: 16 additions & 0 deletions App/SnapGlass/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
16 changes: 16 additions & 0 deletions App/SnapGlass/Resources/ja.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -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" = "ショートカット";
Expand Down
16 changes: 16 additions & 0 deletions App/SnapGlass/Resources/ko.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -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" = "단축키";
Expand Down
16 changes: 16 additions & 0 deletions App/SnapGlass/Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -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" = "快捷键";
Expand Down
Loading
Loading